"""
Layout-Preserving PDF Generation Service
Generates PDF files that preserve the original document layout using OCR JSON data
"""

import json
import logging
import re
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union
from datetime import datetime

from reportlab.lib.pagesizes import A4, letter
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import Table, TableStyle, SimpleDocTemplate, Spacer
from reportlab.platypus import Image as PlatypusImage
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from reportlab.platypus import Paragraph
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from PIL import Image
from html.parser import HTMLParser

from app.core.config import settings
from app.utils.bbox_utils import normalize_bbox

# Import table column corrector for column alignment fix
try:
    from app.services.table_column_corrector import TableColumnCorrector
    TABLE_COLUMN_CORRECTOR_AVAILABLE = True
except ImportError:
    TABLE_COLUMN_CORRECTOR_AVAILABLE = False
    TableColumnCorrector = None

# Import text region renderer for simple text positioning
try:
    from app.services.text_region_renderer import TextRegionRenderer, load_raw_ocr_regions
    TEXT_REGION_RENDERER_AVAILABLE = True
except ImportError:
    TEXT_REGION_RENDERER_AVAILABLE = False
    TextRegionRenderer = None
    load_raw_ocr_regions = None

# Import UnifiedDocument for dual-track support
try:
    from app.models.unified_document import (
        UnifiedDocument, DocumentElement, ElementType,
        BoundingBox, TableData, ProcessingTrack,
        DocumentMetadata, Dimensions, Page, StyleInfo
    )
    UNIFIED_DOCUMENT_AVAILABLE = True
except ImportError:
    UNIFIED_DOCUMENT_AVAILABLE = False
    UnifiedDocument = None

logger = logging.getLogger(__name__)


class HTMLTableParser(HTMLParser):
    """Parse HTML table to extract structure and data"""

    def __init__(self):
        super().__init__()
        self.tables = []
        self.current_table = None
        self.current_row = None
        self.current_cell = None
        self.in_table = False

    def handle_starttag(self, tag, attrs):
        attrs_dict = dict(attrs)

        if tag == 'table':
            self.in_table = True
            self.current_table = {'rows': []}

        elif tag == 'tr' and self.in_table:
            self.current_row = {'cells': []}

        elif tag in ('td', 'th') and self.in_table and self.current_row is not None:
            colspan = int(attrs_dict.get('colspan', 1))
            rowspan = int(attrs_dict.get('rowspan', 1))
            self.current_cell = {
                'text': '',
                'is_header': tag == 'th',
                'colspan': colspan,
                'rowspan': rowspan
            }

    def handle_endtag(self, tag):
        if tag == 'table' and self.in_table:
            if self.current_table and self.current_table['rows']:
                self.tables.append(self.current_table)
            self.current_table = None
            self.in_table = False

        elif tag == 'tr' and self.current_row is not None:
            if self.current_table is not None:
                self.current_table['rows'].append(self.current_row)
            self.current_row = None

        elif tag in ('td', 'th') and self.current_cell is not None:
            if self.current_row is not None:
                self.current_row['cells'].append(self.current_cell)
            self.current_cell = None

    def handle_data(self, data):
        if self.current_cell is not None:
            self.current_cell['text'] += data.strip() + ' '


class PDFGeneratorService:
    """Service for generating layout-preserving PDFs from OCR JSON data"""

    # Font mapping from common fonts to PDF standard fonts
    FONT_MAPPING = {
        'Arial': 'Helvetica',
        'Arial Black': 'Helvetica-Bold',
        'Times New Roman': 'Times-Roman',
        'Times': 'Times-Roman',
        'Courier New': 'Courier',
        'Courier': 'Courier',
        'Calibri': 'Helvetica',
        'Cambria': 'Times-Roman',
        'Georgia': 'Times-Roman',
        'Verdana': 'Helvetica',
        'Tahoma': 'Helvetica',
        'Trebuchet MS': 'Helvetica',
        'Comic Sans MS': 'Helvetica',
        'Impact': 'Helvetica-Bold',
        'Lucida Console': 'Courier',
        'Palatino': 'Times-Roman',
        'Garamond': 'Times-Roman',
        'Bookman': 'Times-Roman',
        'Century Gothic': 'Helvetica',
        'Franklin Gothic': 'Helvetica',
    }

    # Style flags for text formatting
    STYLE_FLAG_BOLD = 1
    STYLE_FLAG_ITALIC = 2
    STYLE_FLAG_UNDERLINE = 4
    STYLE_FLAG_STRIKETHROUGH = 8

    def __init__(self):
        """Initialize PDF generator with font configuration"""
        self.font_name = 'NotoSansSC'
        self.font_path = None
        self.font_registered = False
        self.current_processing_track = None  # Track type for current document

        self._register_chinese_font()

    def _register_chinese_font(self):
        """Register Chinese font for PDF generation"""
        try:
            # Get font path from settings
            font_path = Path(settings.chinese_font_path)

            # Try relative path from project root
            if not font_path.is_absolute():
                # Adjust path - settings.chinese_font_path starts with ./backend/
                project_root = Path(__file__).resolve().parent.parent.parent.parent
                font_path = project_root / font_path

            if not font_path.exists():
                logger.error(f"Chinese font not found at {font_path}")
                return

            # Register font
            pdfmetrics.registerFont(TTFont(self.font_name, str(font_path)))
            self.font_path = font_path
            self.font_registered = True
            logger.info(f"Chinese font registered: {self.font_name} from {font_path}")

        except Exception as e:
            logger.error(f"Failed to register Chinese font: {e}")
            self.font_registered = False

    def _detect_content_orientation(
        self,
        page_width: float,
        page_height: float,
        ocr_data: Dict
    ) -> Tuple[bool, float, float]:
        """
        Detect if content orientation differs from page dimensions.

        This handles cases where a document is scanned in portrait orientation
        but the actual content is landscape (or vice versa). PP-StructureV3
        may return bounding boxes in the "corrected" orientation while the
        image remains in its scanned orientation.

        Args:
            page_width: Declared page width from image dimensions
            page_height: Declared page height from image dimensions
            ocr_data: OCR data dictionary containing bounding boxes

        Returns:
            Tuple of (needs_rotation, adjusted_width, adjusted_height)
            - needs_rotation: True if page orientation should be swapped
            - adjusted_width: Width to use for PDF page
            - adjusted_height: Height to use for PDF page
        """
        # Find max content bounds from all regions
        max_x = 0
        max_y = 0

        all_regions = []

        # Collect regions from various sources
        if 'text_regions' in ocr_data and isinstance(ocr_data['text_regions'], list):
            all_regions.extend(ocr_data['text_regions'])

        if 'layout_data' in ocr_data and isinstance(ocr_data['layout_data'], dict):
            elements = ocr_data['layout_data'].get('elements', [])
            if elements:
                all_regions.extend(elements)

        if 'images_metadata' in ocr_data and isinstance(ocr_data['images_metadata'], list):
            all_regions.extend(ocr_data['images_metadata'])

        for region in all_regions:
            try:
                bbox = region.get('bbox')
                if not bbox:
                    continue

                # Handle different bbox formats
                if isinstance(bbox, dict):
                    # BoundingBox object format
                    max_x = max(max_x, float(bbox.get('x1', bbox.get('x0', 0) + bbox.get('width', 0))))
                    max_y = max(max_y, float(bbox.get('y1', bbox.get('y0', 0) + bbox.get('height', 0))))
                elif isinstance(bbox, (list, tuple)):
                    if len(bbox) >= 4 and isinstance(bbox[0], (int, float)):
                        # [x1, y1, x2, y2] format
                        max_x = max(max_x, float(bbox[2]))
                        max_y = max(max_y, float(bbox[3]))
                    elif isinstance(bbox[0], (list, tuple)):
                        # Polygon format [[x, y], ...]
                        x_coords = [p[0] for p in bbox if len(p) >= 2]
                        y_coords = [p[1] for p in bbox if len(p) >= 2]
                        if x_coords and y_coords:
                            max_x = max(max_x, max(x_coords))
                            max_y = max(max_y, max(y_coords))
            except Exception as e:
                logger.debug(f"Error processing bbox for orientation detection: {e}")
                continue

        if max_x == 0 or max_y == 0:
            # No valid bboxes found, use original dimensions
            return (False, page_width, page_height)

        logger.info(f"內容邊界偵測: max_x={max_x:.1f}, max_y={max_y:.1f}, "
                   f"page_dims={page_width:.1f}x{page_height:.1f}")

        # Calculate how much content extends beyond page boundaries
        x_overflow = max_x / page_width if page_width > 0 else 1
        y_overflow = max_y / page_height if page_height > 0 else 1

        # Check if content significantly exceeds page dimensions in one direction
        # This suggests the content is in a different orientation
        OVERFLOW_THRESHOLD = 1.15  # Content extends >15% beyond declared dimensions

        if x_overflow > OVERFLOW_THRESHOLD and y_overflow <= 1.05:
            # Content is wider than page but fits in height
            # This suggests portrait image with landscape content
            logger.warning(f"偵測到內容方向可能與頁面不符: "
                         f"x_overflow={x_overflow:.2f}, y_overflow={y_overflow:.2f}")

            # Check if swapping dimensions would help
            # If max_x fits better in page_height, swap
            if max_x <= page_height * 1.05:
                logger.info(f"建議頁面旋轉: {page_width:.1f}x{page_height:.1f} -> "
                           f"{page_height:.1f}x{page_width:.1f}")
                return (True, page_height, page_width)
            else:
                # Content still doesn't fit, just scale to fit content
                logger.info(f"內容超出頁面邊界，調整頁面大小以容納內容")
                return (False, max_x * 1.02, page_height)

        elif y_overflow > OVERFLOW_THRESHOLD and x_overflow <= 1.05:
            # Content is taller than page but fits in width
            # Less common - landscape image with portrait content
            logger.warning(f"偵測到內容方向可能與頁面不符 (高度溢出): "
                         f"x_overflow={x_overflow:.2f}, y_overflow={y_overflow:.2f}")

            if max_y <= page_width * 1.05:
                logger.info(f"建議頁面旋轉: {page_width:.1f}x{page_height:.1f} -> "
                           f"{page_height:.1f}x{page_width:.1f}")
                return (True, page_height, page_width)
            else:
                logger.info(f"內容超出頁面邊界，調整頁面大小以容納內容")
                return (False, page_width, max_y * 1.02)

        # No orientation issue detected
        return (False, page_width, page_height)

    def _parse_color(self, color_value) -> Tuple[float, float, float]:
        """
        Parse color value to RGB tuple.

        Args:
            color_value: Color as hex string (#RRGGBB), RGB tuple, or color name

        Returns:
            RGB tuple with values 0-1 for ReportLab
        """
        if not color_value:
            return (0, 0, 0)  # Default to black

        try:
            # Handle hex color (#RRGGBB or #RGB)
            if isinstance(color_value, str) and color_value.startswith('#'):
                hex_color = color_value.lstrip('#')

                # Expand short form (#RGB -> #RRGGBB)
                if len(hex_color) == 3:
                    hex_color = ''.join([c*2 for c in hex_color])

                if len(hex_color) == 6:
                    r = int(hex_color[0:2], 16) / 255.0
                    g = int(hex_color[2:4], 16) / 255.0
                    b = int(hex_color[4:6], 16) / 255.0
                    return (r, g, b)

            # Handle RGB tuple or list
            elif isinstance(color_value, (tuple, list)) and len(color_value) >= 3:
                r, g, b = color_value[0:3]
                # Normalize to 0-1 if values are 0-255
                if any(v > 1 for v in [r, g, b]):
                    return (r/255.0, g/255.0, b/255.0)
                return (r, g, b)

        except (ValueError, TypeError) as e:
            logger.warning(f"Failed to parse color {color_value}: {e}")

        # Default to black
        return (0, 0, 0)

    def _map_font(self, font_name: Optional[str]) -> str:
        """
        Map font name to PDF standard font.

        Args:
            font_name: Original font name

        Returns:
            PDF standard font name
        """
        if not font_name:
            return 'Helvetica'

        # Direct lookup
        if font_name in self.FONT_MAPPING:
            return self.FONT_MAPPING[font_name]

        # Case-insensitive lookup
        font_lower = font_name.lower()
        for orig_font, pdf_font in self.FONT_MAPPING.items():
            if orig_font.lower() == font_lower:
                return pdf_font

        # Partial match for common patterns
        if 'arial' in font_lower:
            return 'Helvetica'
        elif 'times' in font_lower:
            return 'Times-Roman'
        elif 'courier' in font_lower:
            return 'Courier'

        # Default fallback - use NotoSansSC for CJK support if registered
        if self.font_registered:
            logger.debug(f"Font '{font_name}' not found in mapping, using {self.font_name} for CJK support")
            return self.font_name
        else:
            logger.debug(f"Font '{font_name}' not found in mapping, using Helvetica")
            return 'Helvetica'

    def _apply_text_style(self, c: canvas.Canvas, style_info, default_size: float = 12):
        """
        Apply text styling from StyleInfo to PDF canvas.

        Args:
            c: ReportLab canvas object
            style_info: StyleInfo object or dict with font, size, color, flags
            default_size: Default font size if not specified
        """
        if not style_info:
            # Apply default styling
            c.setFont('Helvetica', default_size)
            c.setFillColorRGB(0, 0, 0)
            return

        try:
            # Extract style attributes
            if hasattr(style_info, '__dict__'):
                # StyleInfo object
                font_family = getattr(style_info, 'font_name', None)
                font_size = getattr(style_info, 'font_size', default_size)
                color = getattr(style_info, 'text_color', None)
                font_weight = getattr(style_info, 'font_weight', 'normal')
                font_style = getattr(style_info, 'font_style', 'normal')
                # Legacy flags support
                flags = getattr(style_info, 'flags', 0)
            elif isinstance(style_info, dict):
                # Dictionary
                font_family = style_info.get('font_name')
                font_size = style_info.get('font_size', default_size)
                color = style_info.get('text_color')
                font_weight = style_info.get('font_weight', 'normal')
                font_style = style_info.get('font_style', 'normal')
                # Legacy flags support
                flags = style_info.get('flags', 0)
            else:
                # Unknown format, use defaults
                c.setFont('Helvetica', default_size)
                c.setFillColorRGB(0, 0, 0)
                return

            # Map font name
            base_font = self._map_font(font_family) if font_family else 'Helvetica'

            # Determine bold and italic from font_weight/font_style (preferred) or flags (legacy)
            is_bold = font_weight == 'bold' if font_weight else bool(flags & self.STYLE_FLAG_BOLD)
            is_italic = font_style == 'italic' if font_style else bool(flags & self.STYLE_FLAG_ITALIC)

            # Apply bold/italic modifiers
            if is_bold or is_italic:

                if is_bold and is_italic:
                    # Try bold-italic variant
                    if 'Helvetica' in base_font:
                        base_font = 'Helvetica-BoldOblique'
                    elif 'Times' in base_font:
                        base_font = 'Times-BoldItalic'
                    elif 'Courier' in base_font:
                        base_font = 'Courier-BoldOblique'
                elif is_bold:
                    # Try bold variant
                    if 'Helvetica' in base_font:
                        base_font = 'Helvetica-Bold'
                    elif 'Times' in base_font:
                        base_font = 'Times-Bold'
                    elif 'Courier' in base_font:
                        base_font = 'Courier-Bold'
                elif is_italic:
                    # Try italic variant
                    if 'Helvetica' in base_font:
                        base_font = 'Helvetica-Oblique'
                    elif 'Times' in base_font:
                        base_font = 'Times-Italic'
                    elif 'Courier' in base_font:
                        base_font = 'Courier-Oblique'

            # Apply font and size
            actual_size = font_size if font_size and font_size > 0 else default_size
            try:
                c.setFont(base_font, actual_size)
            except KeyError:
                # Font not available, fallback
                logger.warning(f"Font '{base_font}' not available, using Helvetica")
                c.setFont('Helvetica', actual_size)

            # Apply color
            rgb_color = None
            if hasattr(style_info, 'get_rgb_color'):
                # Use StyleInfo method if available
                rgb_color = style_info.get_rgb_color()
            elif color is not None:
                # Parse from extracted color value
                r, g, b = self._parse_color(color)
                rgb_color = (r, g, b)

            if rgb_color:
                # text_color is in 0-255 range, convert to 0-1 for ReportLab
                r, g, b = rgb_color
                if any(v > 1 for v in [r, g, b]):
                    r, g, b = r/255.0, g/255.0, b/255.0
                c.setFillColorRGB(r, g, b)
            else:
                c.setFillColorRGB(0, 0, 0)  # Default black

        except Exception as e:
            logger.error(f"Failed to apply text style: {e}")
            # Fallback to defaults
            c.setFont('Helvetica', default_size)
            c.setFillColorRGB(0, 0, 0)

    def load_ocr_json(self, json_path: Path) -> Optional[Dict]:
        """
        Load and parse OCR JSON result file

        Args:
            json_path: Path to JSON file

        Returns:
            Parsed JSON data or None if failed
        """
        try:
            with open(json_path, 'r', encoding='utf-8') as f:
                data = json.load(f)

            logger.info(f"Loaded OCR JSON: {json_path.name}")
            return data

        except Exception as e:
            logger.error(f"Failed to load JSON {json_path}: {e}")
            return None

    def _get_image_path(self, element) -> Optional[str]:
        """
        Get image path with fallback logic.

        Checks multiple locations in order:
        1. element.content["saved_path"] - Direct track saved path
        2. element.content["path"] - Legacy path
        3. element.content["image_path"] - Alternative path
        4. element.saved_path - Direct attribute
        5. element.metadata["path"] - Metadata fallback

        Args:
            element: DocumentElement object

        Returns:
            Path to image file or None if not found
        """
        # Check content dictionary
        if isinstance(element.content, dict):
            for key in ['saved_path', 'path', 'image_path']:
                if key in element.content:
                    return element.content[key]

        # Check direct attribute
        if hasattr(element, 'saved_path') and element.saved_path:
            return element.saved_path

        # Check metadata
        if element.metadata and isinstance(element.metadata, dict):
            if 'path' in element.metadata:
                return element.metadata['path']
            if 'saved_path' in element.metadata:
                return element.metadata['saved_path']

        return None

    def convert_unified_document_to_ocr_data(self, unified_doc: 'UnifiedDocument') -> Dict:
        """
        Convert UnifiedDocument to OCR data format for PDF generation.

        This method transforms the UnifiedDocument structure into the legacy
        OCR data format that the PDF generator expects, supporting both
        OCR and DIRECT processing tracks.

        Args:
            unified_doc: UnifiedDocument object from either processing track

        Returns:
            Dictionary in OCR data format with text_regions, images_metadata, layout_data
        """
        text_regions = []
        images_metadata = []
        layout_elements = []

        for page in unified_doc.pages:
            page_num = page.page_number  # 1-based

            for element in page.elements:
                # Convert BoundingBox to polygon format [[x,y], [x,y], [x,y], [x,y]]
                bbox_polygon = [
                    [element.bbox.x0, element.bbox.y0],  # top-left
                    [element.bbox.x1, element.bbox.y0],  # top-right
                    [element.bbox.x1, element.bbox.y1],  # bottom-right
                    [element.bbox.x0, element.bbox.y1],  # bottom-left
                ]

                # Handle text elements
                if element.is_text or element.type in [
                    ElementType.TEXT, ElementType.TITLE, ElementType.HEADER,
                    ElementType.FOOTER, ElementType.PARAGRAPH, ElementType.CAPTION,
                    ElementType.LIST_ITEM, ElementType.FOOTNOTE, ElementType.REFERENCE
                ]:
                    text_content = element.get_text()
                    if text_content:
                        text_region = {
                            'text': text_content,
                            'bbox': bbox_polygon,
                            'confidence': element.confidence or 1.0,
                            'page': page_num,
                            'element_type': element.type.value  # Include element type for styling
                        }

                        # Include style information if available (for Direct track)
                        if hasattr(element, 'style') and element.style:
                            text_region['style'] = element.style

                        text_regions.append(text_region)

                # Handle table elements
                elif element.type == ElementType.TABLE:
                    # Convert TableData to HTML for layout_data
                    if isinstance(element.content, TableData):
                        html_content = element.content.to_html()
                    elif isinstance(element.content, dict):
                        html_content = element.content.get('html', str(element.content))
                    else:
                        html_content = str(element.content)

                    table_element = {
                        'type': 'table',
                        'content': html_content,
                        'bbox': [element.bbox.x0, element.bbox.y0,
                                element.bbox.x1, element.bbox.y1],
                        'page': page_num - 1,  # layout uses 0-based
                        'element_id': element.element_id  # For _use_border_only matching
                    }

                    # Preserve cell_boxes and embedded_images from metadata
                    # These are extracted by PP-StructureV3 and used for accurate table rendering
                    if element.metadata:
                        if 'cell_boxes' in element.metadata:
                            table_element['cell_boxes'] = element.metadata['cell_boxes']
                            table_element['cell_boxes_source'] = element.metadata.get('cell_boxes_source', 'metadata')
                        if 'embedded_images' in element.metadata:
                            table_element['embedded_images'] = element.metadata['embedded_images']
                        # Pass through rebuild flag - rebuilt tables should use HTML content
                        if element.metadata.get('was_rebuilt'):
                            table_element['was_rebuilt'] = True
                            logger.debug(f"Table {element.element_id}: marked as rebuilt")

                    layout_elements.append(table_element)

                    # Add bbox to images_metadata for text overlap filtering
                    # (no actual image file, just bbox for filtering)
                    img_metadata = {
                        'image_path': None,  # No fake table image
                        'bbox': bbox_polygon,
                        'page': page_num - 1,  # 0-based for images_metadata
                        'type': 'table',
                        'element_id': element.element_id
                    }
                    # Also copy cell_boxes for quality checking
                    if element.metadata and 'cell_boxes' in element.metadata:
                        img_metadata['cell_boxes'] = element.metadata['cell_boxes']
                    # Mark if table was rebuilt
                    if element.metadata and element.metadata.get('was_rebuilt'):
                        img_metadata['was_rebuilt'] = True
                    images_metadata.append(img_metadata)

                # Handle image/visual elements (including stamps/seals)
                elif element.is_visual or element.type in [
                    ElementType.IMAGE, ElementType.FIGURE, ElementType.CHART,
                    ElementType.DIAGRAM, ElementType.LOGO, ElementType.STAMP
                ]:
                    # Get image path using fallback logic
                    image_path = self._get_image_path(element)

                    # Only add if we found a valid path
                    if image_path:
                        images_metadata.append({
                            'image_path': image_path,
                            'bbox': bbox_polygon,
                            'page': page_num - 1,  # 0-based
                            'type': element.type.value
                        })
                        logger.debug(f"Found image path: {image_path} for element {element.element_id}")
                    else:
                        logger.warning(f"No image path found for visual element {element.element_id}")

        # Build page dimensions mapping for multi-page support
        page_dimensions = {}
        for page in unified_doc.pages:
            page_dimensions[page.page_number - 1] = {  # 0-based index
                'width': page.dimensions.width,
                'height': page.dimensions.height
            }

        # Build OCR data structure
        ocr_data = {
            'text_regions': text_regions,
            'images_metadata': images_metadata,
            'layout_data': {
                'elements': layout_elements,
                'total_elements': len(layout_elements)
            },
            'total_pages': unified_doc.page_count,
            'ocr_dimensions': {
                'width': unified_doc.pages[0].dimensions.width if unified_doc.pages else 0,
                'height': unified_doc.pages[0].dimensions.height if unified_doc.pages else 0
            },
            'page_dimensions': page_dimensions,  # Per-page dimensions for multi-page support
            # Metadata for tracking
            '_from_unified_document': True,
            '_processing_track': unified_doc.metadata.processing_track.value
        }

        logger.info(f"Converted UnifiedDocument to OCR data: "
                   f"{len(text_regions)} text regions, "
                   f"{len(images_metadata)} images, "
                   f"{len(layout_elements)} layout elements, "
                   f"track={unified_doc.metadata.processing_track.value}")

        return ocr_data

    def generate_from_unified_document(
        self,
        unified_doc: 'UnifiedDocument',
        output_path: Path,
        source_file_path: Optional[Path] = None
    ) -> bool:
        """
        Generate layout-preserving PDF directly from UnifiedDocument.

        This method supports both OCR and DIRECT processing tracks,
        preserving layout and coordinate information from either source.

        Args:
            unified_doc: UnifiedDocument object
            output_path: Path to save generated PDF
            source_file_path: Optional path to original source file

        Returns:
            True if successful, False otherwise
        """
        if not UNIFIED_DOCUMENT_AVAILABLE:
            logger.error("UnifiedDocument support not available")
            return False

        try:
            # Detect processing track for track-specific rendering
            processing_track = None
            if hasattr(unified_doc, 'metadata') and unified_doc.metadata:
                if hasattr(unified_doc.metadata, 'processing_track'):
                    processing_track = unified_doc.metadata.processing_track
                elif isinstance(unified_doc.metadata, dict):
                    processing_track = unified_doc.metadata.get('processing_track')

            # Route to track-specific rendering method
            # ProcessingTrack is (str, Enum), so comparing with enum value works for both string and enum
            # HYBRID track uses Direct track rendering (Direct text/tables + OCR images)
            is_direct_track = (processing_track == ProcessingTrack.DIRECT or
                               processing_track == ProcessingTrack.HYBRID)

            logger.info(f"Processing track: {processing_track}, using {'Direct' if is_direct_track else 'OCR'} track rendering")

            if is_direct_track:
                # Direct track: Rich formatting preservation
                return self._generate_direct_track_pdf(
                    unified_doc=unified_doc,
                    output_path=output_path,
                    source_file_path=source_file_path
                )
            else:
                # OCR track: Simplified rendering (backward compatible)
                return self._generate_ocr_track_pdf(
                    unified_doc=unified_doc,
                    output_path=output_path,
                    source_file_path=source_file_path
                )

        except Exception as e:
            logger.error(f"Failed to generate PDF from UnifiedDocument: {e}")
            import traceback
            traceback.print_exc()
            return False

    def _is_element_inside_regions(self, element_bbox, regions_elements, overlap_threshold=0.5) -> bool:
        """
        Check if an element overlaps significantly with any exclusion region (table, image).

        This prevents duplicate rendering when text overlaps with tables/images.
        Direct extraction often extracts both the structured element (table/image)
        AND its text content as separate text blocks.

        Uses overlap ratio detection instead of strict containment, since text blocks
        from DirectExtractionEngine may be larger than detected table/image regions
        (e.g., text block includes heading above table).

        Args:
            element_bbox: BBox of the element to check
            regions_elements: List of region elements (tables, images) to check against
            overlap_threshold: Minimum overlap percentage to trigger filtering (default 0.5 = 50%)

        Returns:
            True if element overlaps ≥50% with any region, False otherwise
        """
        if not element_bbox:
            return False

        e_x0, e_y0, e_x1, e_y1 = element_bbox.x0, element_bbox.y0, element_bbox.x1, element_bbox.y1
        elem_area = (e_x1 - e_x0) * (e_y1 - e_y0)

        if elem_area <= 0:
            return False

        for region in regions_elements:
            r_bbox = region.bbox
            if not r_bbox:
                continue

            # Calculate overlap rectangle
            overlap_x0 = max(e_x0, r_bbox.x0)
            overlap_y0 = max(e_y0, r_bbox.y0)
            overlap_x1 = min(e_x1, r_bbox.x1)
            overlap_y1 = min(e_y1, r_bbox.y1)

            # Check if there is any overlap
            if overlap_x0 < overlap_x1 and overlap_y0 < overlap_y1:
                # Calculate overlap area
                overlap_area = (overlap_x1 - overlap_x0) * (overlap_y1 - overlap_y0)
                overlap_ratio = overlap_area / elem_area

                # If element overlaps more than threshold, filter it out
                if overlap_ratio >= overlap_threshold:
                    return True

        return False

    def _generate_direct_track_pdf(
        self,
        unified_doc: 'UnifiedDocument',
        output_path: Path,
        source_file_path: Optional[Path] = None
    ) -> bool:
        """
        Generate PDF with rich formatting preservation for Direct track.

        This method processes UnifiedDocument directly without converting to
        legacy OCR format, preserving StyleInfo and applying proper text
        formatting including line breaks.

        Args:
            unified_doc: UnifiedDocument from Direct extraction
            output_path: Path to save generated PDF
            source_file_path: Optional path to original source file

        Returns:
            True if successful, False otherwise
        """
        try:
            logger.info("=== Direct Track PDF Generation ===")
            logger.info(f"Total pages: {len(unified_doc.pages)}")

            # Set current track for helper methods (may be DIRECT or HYBRID)
            if hasattr(unified_doc, 'metadata') and unified_doc.metadata:
                self.current_processing_track = unified_doc.metadata.processing_track
            else:
                self.current_processing_track = ProcessingTrack.DIRECT

            # Get page dimensions from first page (for canvas initialization)
            if not unified_doc.pages:
                logger.error("No pages in document")
                return False

            first_page = unified_doc.pages[0]
            page_width = first_page.dimensions.width
            page_height = first_page.dimensions.height

            logger.info(f"First page dimensions: {page_width} x {page_height}")

            # Create PDF canvas with first page dimensions (will be updated per page)
            from reportlab.pdfgen import canvas
            pdf_canvas = canvas.Canvas(str(output_path), pagesize=(page_width, page_height))

            # Process each page
            for page_idx, page in enumerate(unified_doc.pages):
                logger.info(f">>> Processing page {page_idx + 1}/{len(unified_doc.pages)}")

                # Get current page dimensions
                current_page_width = page.dimensions.width
                current_page_height = page.dimensions.height
                logger.info(f"Page {page_idx + 1} dimensions: {current_page_width} x {current_page_height}")

                if page_idx > 0:
                    pdf_canvas.showPage()

                # Set page size for current page
                pdf_canvas.setPageSize((current_page_width, current_page_height))

                # Separate elements by type
                text_elements = []
                table_elements = []
                image_elements = []
                list_elements = []

                # FIX: Collect exclusion regions (tables, images) to prevent duplicate rendering
                regions_to_avoid = []

                # Calculate page area for background detection
                page_area = current_page_width * current_page_height

                for element in page.elements:
                    if element.type == ElementType.TABLE:
                        table_elements.append(element)
                        regions_to_avoid.append(element)  # Tables are exclusion regions
                    elif element.is_visual or element.type in [
                        ElementType.IMAGE, ElementType.FIGURE,
                        ElementType.CHART, ElementType.DIAGRAM, ElementType.LOGO, ElementType.STAMP
                    ]:
                        # Skip large vector_graphics charts in Direct track
                        # These are visual decorations (borders, lines, frames) that would cover text
                        # PyMuPDF extracts both vector graphics as images AND text layer separately
                        if element.type == ElementType.CHART and element.bbox:
                            content = element.content
                            is_vector_graphics = (
                                isinstance(content, dict) and
                                content.get('source') == 'vector_graphics'
                            )
                            if is_vector_graphics:
                                elem_area = (element.bbox.x1 - element.bbox.x0) * (element.bbox.y1 - element.bbox.y0)
                                coverage_ratio = elem_area / page_area if page_area > 0 else 0
                                if coverage_ratio > 0.5:
                                    logger.info(f"Skipping large vector_graphics chart {element.element_id} "
                                              f"(covers {coverage_ratio*100:.1f}% of page) - text provides actual content")
                                    continue

                        image_elements.append(element)
                        # Only add real images to exclusion regions, NOT charts/diagrams
                        # Charts often have large bounding boxes that include text labels
                        # which should be rendered as selectable text on top
                        if element.type in [ElementType.IMAGE, ElementType.FIGURE, ElementType.LOGO, ElementType.STAMP]:
                            # Check if this is Direct track (text from PDF text layer, not OCR)
                            is_direct = (self.current_processing_track == ProcessingTrack.DIRECT or
                                        self.current_processing_track == ProcessingTrack.HYBRID)

                            if is_direct:
                                # Direct track: text is from PDF text layer, not OCR'd from images
                                # Don't exclude any images - text should be rendered on top
                                # This is critical for Office documents with background images
                                logger.debug(f"Direct track: not excluding {element.element_id} from text regions")
                                continue

                            # OCR track: Skip full-page background images from exclusion regions
                            # Smaller images that might contain OCR'd text should still be excluded
                            if element.bbox:
                                elem_area = (element.bbox.x1 - element.bbox.x0) * (element.bbox.y1 - element.bbox.y0)
                                coverage_ratio = elem_area / page_area if page_area > 0 else 0

                                # If image covers >70% of page, it's likely a background - don't exclude text
                                if coverage_ratio > 0.7:
                                    logger.debug(f"OCR track: skipping background image {element.element_id} from exclusion "
                                               f"(covers {coverage_ratio*100:.1f}% of page)")
                                    continue

                            regions_to_avoid.append(element)
                    elif element.type == ElementType.LIST_ITEM:
                        list_elements.append(element)
                    elif self._is_list_item_fallback(element):
                        # Fallback detection: Check metadata and text patterns
                        list_elements.append(element)
                        # Mark as list item for downstream processing
                        element.type = ElementType.LIST_ITEM
                    elif element.is_text or element.type in [
                        ElementType.TEXT, ElementType.TITLE, ElementType.HEADER,
                        ElementType.FOOTER, ElementType.PARAGRAPH,
                        ElementType.FOOTNOTE, ElementType.REFERENCE,
                        ElementType.EQUATION, ElementType.CAPTION
                    ]:
                        text_elements.append(element)

                logger.info(f"Page {page_idx + 1}: {len(text_elements)} text, "
                           f"{len(table_elements)} tables, {len(image_elements)} images, "
                           f"{len(list_elements)} list items")

                # Use original element order from extraction engine
                # The extraction engine has already sorted elements by reading order,
                # handling multi-column layouts correctly (top-to-bottom, left-to-right)
                all_elements = []

                # Preserve original order by iterating through page.elements
                for elem in page.elements:
                    if elem in image_elements:
                        all_elements.append(('image', elem))
                    elif elem in table_elements:
                        all_elements.append(('table', elem))
                    elif elem in list_elements:
                        all_elements.append(('list', elem))
                    elif elem in text_elements:
                        all_elements.append(('text', elem))

                logger.debug(f"Drawing {len(all_elements)} elements in extraction order (preserves multi-column reading order)")
                logger.debug(f"Exclusion regions: {len(regions_to_avoid)} (tables/images/charts)")

                # Debug: Log exclusion region types
                region_types = {}
                for region in regions_to_avoid:
                    region_type = region.type.name
                    region_types[region_type] = region_types.get(region_type, 0) + 1
                if region_types:
                    logger.debug(f"  Exclusion region breakdown: {region_types}")

                # Draw elements in document order
                for elem_type, elem in all_elements:
                    if elem_type == 'image':
                        self._draw_image_element_direct(pdf_canvas, elem, current_page_height, output_path.parent)
                    elif elem_type == 'table':
                        self._draw_table_element_direct(pdf_canvas, elem, current_page_height)
                    elif elem_type == 'list':
                        # FIX: Check if list item overlaps with table/image
                        if not self._is_element_inside_regions(elem.bbox, regions_to_avoid):
                            self._draw_text_element_direct(pdf_canvas, elem, current_page_height)
                        else:
                            logger.debug(f"Skipping list element {elem.element_id} inside table/image region")
                    elif elem_type == 'text':
                        # FIX: Check if text overlaps with table/image before drawing
                        if not self._is_element_inside_regions(elem.bbox, regions_to_avoid):
                            self._draw_text_element_direct(pdf_canvas, elem, current_page_height)
                        else:
                            logger.debug(f"Skipping text element {elem.element_id} inside table/image region")

            # Save PDF
            pdf_canvas.save()
            logger.info(f"Direct track PDF saved to {output_path}")

            # Reset track
            self.current_processing_track = None
            return True

        except Exception as e:
            logger.error(f"Failed to generate Direct track PDF: {e}")
            import traceback
            traceback.print_exc()
            self.current_processing_track = None
            return False

    def _generate_ocr_track_pdf(
        self,
        unified_doc: 'UnifiedDocument',
        output_path: Path,
        source_file_path: Optional[Path] = None
    ) -> bool:
        """
        Generate PDF with simplified rendering for OCR track.

        This method uses the existing OCR data conversion and rendering
        pipeline for backward compatibility.

        Args:
            unified_doc: UnifiedDocument from OCR processing
            output_path: Path to save generated PDF
            source_file_path: Optional path to original source file

        Returns:
            True if successful, False otherwise
        """
        try:
            logger.info("=== OCR Track PDF Generation ===")

            # Set current track
            self.current_processing_track = 'ocr'

            # Check if simple text positioning mode is enabled
            if (settings.simple_text_positioning_enabled and
                TEXT_REGION_RENDERER_AVAILABLE):
                logger.info("Using simple text positioning mode")
                result = self._generate_simple_text_pdf(
                    unified_doc=unified_doc,
                    output_path=output_path,
                    source_file_path=source_file_path
                )
            else:
                # Convert UnifiedDocument to OCR data format (legacy)
                ocr_data = self.convert_unified_document_to_ocr_data(unified_doc)

                # Use existing generation pipeline
                result = self._generate_pdf_from_data(
                    ocr_data=ocr_data,
                    output_path=output_path,
                    source_file_path=source_file_path
                )

            # Reset track
            self.current_processing_track = None
            return result

        except Exception as e:
            logger.error(f"Failed to generate OCR track PDF: {e}")
            import traceback
            traceback.print_exc()
            self.current_processing_track = None
            return False

    def _generate_simple_text_pdf(
        self,
        unified_doc: 'UnifiedDocument',
        output_path: Path,
        source_file_path: Optional[Path] = None
    ) -> bool:
        """
        Generate PDF using simple text positioning from raw OCR regions.

        This approach bypasses complex table structure reconstruction and renders
        raw OCR text directly at detected positions with rotation correction.
        Images, charts, figures, seals, and formulas are still rendered normally.

        Args:
            unified_doc: UnifiedDocument from OCR processing
            output_path: Path to save generated PDF
            source_file_path: Optional path to original source file

        Returns:
            True if successful, False otherwise
        """
        try:
            logger.info("=== Simple Text Positioning PDF Generation ===")

            # Initialize text region renderer
            text_renderer = TextRegionRenderer(
                font_name=self.font_name,
                debug=settings.simple_text_positioning_debug
            )

            # Get result directory from output_path
            result_dir = output_path.parent

            # Try to determine task_id from result directory or output filename
            # Output path is typically: result_dir/task_id_edited.pdf
            task_id = None
            if output_path.stem.endswith('_edited'):
                task_id = output_path.stem.replace('_edited', '')
            elif result_dir.name:
                # result_dir is typically the task_id directory
                task_id = result_dir.name

            if not task_id:
                logger.warning("Could not determine task_id, falling back to legacy method")
                ocr_data = self.convert_unified_document_to_ocr_data(unified_doc)
                return self._generate_pdf_from_data(
                    ocr_data=ocr_data,
                    output_path=output_path,
                    source_file_path=source_file_path
                )

            logger.info(f"Task ID: {task_id}, Result dir: {result_dir}")

            # Get total pages from UnifiedDocument
            total_pages = len(unified_doc.pages) if unified_doc.pages else 1

            # Get page dimensions from first page (for canvas initialization)
            if not unified_doc.pages:
                logger.error("No pages in document")
                return False

            first_page = unified_doc.pages[0]
            if hasattr(first_page, 'dimensions') and first_page.dimensions:
                page_width = float(first_page.dimensions.width)
                page_height = float(first_page.dimensions.height)
            else:
                # Fallback to default size
                page_width = 612.0  # Letter width
                page_height = 792.0  # Letter height
                logger.warning(f"No page dimensions found, using default {page_width}x{page_height}")

            logger.info(f"Initial page size: {page_width:.1f} x {page_height:.1f}")

            # Create PDF canvas
            pdf_canvas = canvas.Canvas(str(output_path), pagesize=(page_width, page_height))

            # Collect image-type elements from UnifiedDocument for rendering
            # Types that should be rendered as images: figure, image, chart, seal, formula
            image_element_types = {'figure', 'image', 'chart', 'seal', 'formula'}

            # Process each page
            for page_num in range(1, total_pages + 1):
                logger.info(f">>> Processing page {page_num}/{total_pages}")

                # Get page dimensions for current page
                if page_num <= len(unified_doc.pages):
                    current_page = unified_doc.pages[page_num - 1]
                    if hasattr(current_page, 'dimensions') and current_page.dimensions:
                        current_width = float(current_page.dimensions.width)
                        current_height = float(current_page.dimensions.height)
                    else:
                        current_width = page_width
                        current_height = page_height
                else:
                    current_width = page_width
                    current_height = page_height

                if page_num > 1:
                    pdf_canvas.showPage()

                # Set page size
                pdf_canvas.setPageSize((current_width, current_height))

                # === Layer 1: Render images, charts, figures, seals, formulas ===
                # Also collect exclusion zones for text avoidance
                exclusion_zones = []  # List of (x0, y0, x1, y1) tuples

                if page_num <= len(unified_doc.pages):
                    current_page = unified_doc.pages[page_num - 1]
                    page_elements = current_page.elements if hasattr(current_page, 'elements') else []

                    image_elements_rendered = 0
                    for elem in page_elements:
                        elem_type = elem.type if hasattr(elem, 'type') else elem.get('type', '')
                        # Handle enum type
                        if hasattr(elem_type, 'value'):
                            elem_type = elem_type.value

                        if elem_type in image_element_types:
                            # Get image path from element content
                            content = elem.content if hasattr(elem, 'content') else elem.get('content', {})
                            if isinstance(content, dict):
                                saved_path = content.get('saved_path') or content.get('path')
                            else:
                                saved_path = None

                            # Get bbox for exclusion zone (even if image file not found)
                            bbox = elem.bbox if hasattr(elem, 'bbox') else elem.get('bbox', {})
                            if hasattr(bbox, 'x0'):
                                x0, y0, x1, y1 = bbox.x0, bbox.y0, bbox.x1, bbox.y1
                            elif isinstance(bbox, dict):
                                x0 = bbox.get('x0', 0)
                                y0 = bbox.get('y0', 0)
                                x1 = bbox.get('x1', x0 + bbox.get('width', 0))
                                y1 = bbox.get('y1', y0 + bbox.get('height', 0))
                            else:
                                continue

                            # Add to exclusion zones for text avoidance
                            # Use original image coordinates (not PDF flipped)
                            exclusion_zones.append((x0, y0, x1, y1))

                            if saved_path:
                                # Try to find the image file
                                image_path = result_dir / saved_path
                                if not image_path.exists():
                                    # Try in imgs subdirectory
                                    image_path = result_dir / 'imgs' / saved_path
                                if not image_path.exists():
                                    # Try just the filename
                                    image_path = result_dir / Path(saved_path).name

                                if image_path.exists():
                                    try:
                                        # Convert coordinates (flip Y for PDF)
                                        pdf_x = x0
                                        pdf_y = current_height - y1  # Bottom of image in PDF coords
                                        img_width = x1 - x0
                                        img_height = y1 - y0

                                        # Draw image
                                        pdf_canvas.drawImage(
                                            str(image_path),
                                            pdf_x, pdf_y,
                                            width=img_width,
                                            height=img_height,
                                            preserveAspectRatio=True,
                                            mask='auto'
                                        )
                                        image_elements_rendered += 1
                                        logger.debug(f"Rendered {elem_type}: {saved_path} at ({pdf_x:.1f}, {pdf_y:.1f})")
                                    except Exception as e:
                                        logger.warning(f"Failed to render {elem_type} {saved_path}: {e}")
                                else:
                                    logger.warning(f"Image file not found: {saved_path}")

                        # Also check for embedded images in table elements
                        # These are images detected inside table regions by PP-Structure
                        elif elem_type == 'table':
                            metadata = elem.metadata if hasattr(elem, 'metadata') else elem.get('metadata', {})
                            embedded_images = metadata.get('embedded_images', []) if metadata else []
                            for emb_img in embedded_images:
                                emb_bbox = emb_img.get('bbox', [])
                                if emb_bbox and len(emb_bbox) >= 4:
                                    ex0, ey0, ex1, ey1 = emb_bbox[0], emb_bbox[1], emb_bbox[2], emb_bbox[3]
                                    exclusion_zones.append((ex0, ey0, ex1, ey1))

                                    # Also render the embedded image
                                    saved_path = emb_img.get('saved_path', '')
                                    if saved_path:
                                        image_path = result_dir / saved_path
                                        if not image_path.exists():
                                            image_path = result_dir / Path(saved_path).name
                                        if image_path.exists():
                                            try:
                                                pdf_x = ex0
                                                pdf_y = current_height - ey1
                                                img_width = ex1 - ex0
                                                img_height = ey1 - ey0
                                                pdf_canvas.drawImage(
                                                    str(image_path),
                                                    pdf_x, pdf_y,
                                                    width=img_width,
                                                    height=img_height,
                                                    preserveAspectRatio=True,
                                                    mask='auto'
                                                )
                                                image_elements_rendered += 1
                                                logger.debug(f"Rendered embedded image: {saved_path} at ({pdf_x:.1f}, {pdf_y:.1f})")
                                            except Exception as e:
                                                logger.warning(f"Failed to render embedded image {saved_path}: {e}")

                    if image_elements_rendered > 0:
                        logger.info(f"Rendered {image_elements_rendered} image elements (figures/charts/seals/formulas/embedded)")

                    if exclusion_zones:
                        logger.info(f"Collected {len(exclusion_zones)} exclusion zones for text avoidance")

                # === Layer 2: Render text from raw OCR regions ===
                raw_regions = load_raw_ocr_regions(str(result_dir), task_id, page_num)

                if not raw_regions:
                    logger.warning(f"No raw OCR regions found for page {page_num}")
                else:
                    logger.info(f"Loaded {len(raw_regions)} raw OCR regions for page {page_num}")

                    # Collect texts inside exclusion zones for position-aware deduplication
                    # This prevents duplicate axis labels from being rendered near charts
                    zone_texts = None
                    if exclusion_zones:
                        zone_texts = text_renderer.collect_zone_texts(
                            raw_regions, exclusion_zones, threshold=0.5, include_axis_labels=True
                        )
                        if zone_texts:
                            logger.info(f"Collected {len(zone_texts)} zone texts for deduplication: {list(zone_texts)[:10]}...")

                    # Render all text regions, avoiding exclusion zones (images/charts)
                    # Scale factors are 1.0 since OCR dimensions match page dimensions
                    rendered = text_renderer.render_all_regions(
                        pdf_canvas=pdf_canvas,
                        regions=raw_regions,
                        page_height=current_height,
                        scale_x=1.0,
                        scale_y=1.0,
                        exclusion_zones=exclusion_zones,
                        zone_texts=zone_texts
                    )

                    logger.info(f"Rendered {rendered} text regions")

                logger.info(f"<<< Page {page_num} complete")

            # Save PDF
            pdf_canvas.save()

            file_size = output_path.stat().st_size
            logger.info(f"Generated PDF: {output_path.name} ({file_size} bytes)")
            return True

        except Exception as e:
            logger.error(f"Failed to generate simple text PDF: {e}")
            import traceback
            traceback.print_exc()
            return False

    def _generate_pdf_from_data(
        self,
        ocr_data: Dict,
        output_path: Path,
        source_file_path: Optional[Path] = None,
        json_parent_dir: Optional[Path] = None
    ) -> bool:
        """
        Internal method to generate PDF from OCR data dictionary.

        This is the core generation logic extracted for reuse by both
        JSON-based and UnifiedDocument-based generation paths.

        Args:
            ocr_data: OCR data dictionary
            output_path: Path to save generated PDF
            source_file_path: Optional path to original source file
            json_parent_dir: Directory containing images (for JSON-based generation)

        Returns:
            True if successful, False otherwise
        """
        try:
            # Note: Removed PDF caching - always regenerate to ensure latest code changes take effect
            # If caching is needed, implement at a higher level with proper cache invalidation

            # Get text regions
            text_regions = ocr_data.get('text_regions', [])
            if not text_regions:
                logger.warning("No text regions found in data")
                # Don't fail - might have only tables/images

            # Get images metadata
            images_metadata = ocr_data.get('images_metadata', [])

            # Get layout data
            layout_data = ocr_data.get('layout_data', {})

            # Step 1: Get OCR processing dimensions (for first page / default)
            ocr_width, ocr_height = self.calculate_page_dimensions(ocr_data, source_file_path=None)
            logger.info(f"OCR 處理時使用的座標系尺寸 (第一頁): {ocr_width:.1f} x {ocr_height:.1f}")

            # Step 2: Get page dimensions mapping for multi-page support
            page_dimensions = ocr_data.get('page_dimensions', {})
            if not page_dimensions:
                # Fallback: use first page dimensions for all pages
                page_dimensions = {0: {'width': ocr_width, 'height': ocr_height}}
                logger.info("No page_dimensions found, using first page size for all pages")

            # Step 3: Get original file dimensions for all pages
            # For OCR track, we use OCR coordinate system dimensions directly to avoid scaling issues
            original_page_sizes = {}
            use_ocr_dimensions_for_pdf = (self.current_processing_track == 'ocr')

            if use_ocr_dimensions_for_pdf:
                # OCR Track: Use OCR coordinate system dimensions directly
                # This ensures no scaling is needed (scale = 1.0)
                logger.info(f"OCR Track: 使用 OCR 座標系尺寸作為 PDF 頁面尺寸（避免縮放）")
            elif source_file_path:
                original_page_sizes = self.get_all_page_sizes(source_file_path)
                if original_page_sizes:
                    logger.info(f"從原始文件獲取到 {len(original_page_sizes)} 頁尺寸")
                else:
                    logger.warning(f"無法獲取原始文件尺寸，將使用 OCR/UnifiedDocument 尺寸")
            else:
                logger.info(f"無原始文件，將使用 OCR/UnifiedDocument 尺寸")

            # Determine initial canvas size (will be updated per page)
            # Priority for OCR track: OCR dimensions (no scaling)
            # Priority for Direct track: original file first page > OCR/UnifiedDocument first page
            if use_ocr_dimensions_for_pdf:
                target_width, target_height = ocr_width, ocr_height
                logger.info(f"初始 PDF 尺寸（OCR Track, 使用 OCR 座標系）: {target_width:.1f} x {target_height:.1f}")
            elif 0 in original_page_sizes:
                target_width, target_height = original_page_sizes[0]
                logger.info(f"初始 PDF 尺寸（來自原始文件首頁）: {target_width:.1f} x {target_height:.1f}")
            else:
                target_width, target_height = ocr_width, ocr_height
                logger.info(f"初始 PDF 尺寸（來自 OCR/UnifiedDocument）: {target_width:.1f} x {target_height:.1f}")

            # Step 4: Detect content orientation mismatch
            # This handles rotated scans where content bbox exceeds page dimensions
            # IMPORTANT: Use OCR dimensions (pixels) for detection, not PDF points
            # because content bboxes are in the same coordinate system as OCR dimensions
            needs_rotation, adjusted_ocr_width, adjusted_ocr_height = self._detect_content_orientation(
                ocr_width, ocr_height, ocr_data
            )

            # If orientation change detected, calculate the adjusted target dimensions
            if needs_rotation:
                # Swap target dimensions to match the detected orientation
                adjusted_width = target_height
                adjusted_height = target_width
            elif adjusted_ocr_width != ocr_width or adjusted_ocr_height != ocr_height:
                # Content extends beyond OCR dimensions, scale target proportionally
                scale_w = adjusted_ocr_width / ocr_width if ocr_width > 0 else 1.0
                scale_h = adjusted_ocr_height / ocr_height if ocr_height > 0 else 1.0
                adjusted_width = target_width * scale_w
                adjusted_height = target_height * scale_h
            else:
                adjusted_width = target_width
                adjusted_height = target_height

            if needs_rotation or (adjusted_width != target_width or adjusted_height != target_height):
                logger.info(f"頁面尺寸調整: {target_width:.1f}x{target_height:.1f} -> "
                           f"{adjusted_width:.1f}x{adjusted_height:.1f} (旋轉={needs_rotation})")
                target_width, target_height = adjusted_width, adjusted_height

                # Update original_page_sizes with the new TARGET dimensions
                if 0 in original_page_sizes:
                    original_page_sizes[0] = (target_width, target_height)
                    logger.info(f"覆蓋原始文件尺寸以適應內容方向")

                # CRITICAL: Update page_dimensions with SWAPPED OCR dimensions
                # This is the coordinate system that the content bboxes are in
                # When content is rotated, width and height are effectively swapped
                if needs_rotation and 0 in page_dimensions:
                    # Swap the OCR dimensions to match the rotated content coordinate system
                    original_ocr_w = page_dimensions[0]['width']
                    original_ocr_h = page_dimensions[0]['height']
                    page_dimensions[0] = {'width': original_ocr_h, 'height': original_ocr_w}
                    logger.info(f"旋轉 OCR 座標系: {original_ocr_w:.1f}x{original_ocr_h:.1f} -> "
                               f"{original_ocr_h:.1f}x{original_ocr_w:.1f}")

            # Create PDF canvas with initial page size (will be updated per page)
            pdf_canvas = canvas.Canvas(str(output_path), pagesize=(target_width, target_height))

            # Smart filtering: only include tables with good cell_boxes quality in regions_to_avoid
            # Tables with bad cell_boxes will use raw OCR text positioning instead
            # Exception: Rebuilt tables always use HTML content and filter text
            regions_to_avoid = []
            good_quality_tables = []
            bad_quality_tables = []
            rebuilt_tables = []

            for img in images_metadata:
                if img.get('type') == 'table':
                    elem_id = img.get('element_id', 'unknown')

                    # Check if this table was rebuilt - rebuilt tables have good content
                    was_rebuilt = img.get('was_rebuilt', False)

                    if was_rebuilt:
                        # Rebuilt tables have accurate content - filter text, use HTML
                        regions_to_avoid.append(img)
                        rebuilt_tables.append(elem_id)
                    else:
                        # Check cell_boxes quality for non-rebuilt tables
                        cell_boxes = img.get('cell_boxes', [])
                        quality = self._check_cell_boxes_quality(cell_boxes, elem_id)

                        if quality == 'good':
                            # Good quality: filter text, render with cell_boxes
                            regions_to_avoid.append(img)
                            good_quality_tables.append(elem_id)
                        else:
                            # Bad quality: don't filter text, just draw border
                            bad_quality_tables.append(elem_id)
                            img['_use_border_only'] = True  # Mark for border-only rendering
                else:
                    # Non-table elements (images, figures, charts) always avoid
                    regions_to_avoid.append(img)

            logger.info(f"過濾文字區域: {len(regions_to_avoid)} 個區域需要避免")
            if rebuilt_tables:
                logger.info(f"  重建表格用 HTML: {rebuilt_tables}")
            if good_quality_tables:
                logger.info(f"  表格用 cell_boxes: {good_quality_tables}")
            if bad_quality_tables:
                logger.info(f"  表格用 raw OCR text (border only): {bad_quality_tables}")

            filtered_text_regions = self._filter_text_in_regions(text_regions, regions_to_avoid)

            # Group regions by page
            pages_data = {}
            for region in filtered_text_regions:
                page_num = region.get('page', 1)
                if page_num not in pages_data:
                    pages_data[page_num] = []
                pages_data[page_num].append(region)

            # Get table elements from layout_data and copy _use_border_only flags
            table_elements = []
            if layout_data and layout_data.get('elements'):
                # Create a lookup for _use_border_only flags from images_metadata
                border_only_tables = {img.get('element_id') for img in images_metadata
                                      if img.get('type') == 'table' and img.get('_use_border_only')}

                logger.debug(f"[DEBUG] border_only_tables from images_metadata: {border_only_tables}")

                for e in layout_data['elements']:
                    if e.get('type') == 'table':
                        elem_id = e.get('element_id')
                        logger.debug(f"[DEBUG] layout_data table element_id: {elem_id}")
                        # Copy the flag if this table should use border only
                        if elem_id in border_only_tables:
                            e['_use_border_only'] = True
                            logger.info(f"[DEBUG] Set _use_border_only=True for table {elem_id}")
                        table_elements.append(e)

            # Process each page
            total_pages = ocr_data.get('total_pages', 1)
            logger.info(f"開始處理 {total_pages} 頁 PDF")

            # Determine image directory
            if json_parent_dir is None:
                json_parent_dir = output_path.parent

            for page_num in range(1, total_pages + 1):
                logger.info(f">>> 處理第 {page_num}/{total_pages} 頁")

                # Get current page dimensions with priority order:
                # For OCR Track: always use OCR dimensions (scale = 1.0)
                # For Direct Track:
                #   1. Original file dimensions (highest priority)
                #   2. OCR/UnifiedDocument dimensions
                #   3. Fallback to first page dimensions
                page_idx = page_num - 1
                dimension_source = "unknown"

                # For OCR Track: always use OCR dimensions
                if use_ocr_dimensions_for_pdf and page_idx in page_dimensions:
                    current_page_dims = page_dimensions[page_idx]
                    current_target_w = float(current_page_dims['width'])
                    current_target_h = float(current_page_dims['height'])
                    dimension_source = "ocr_track_direct"

                # Priority 1: Original file dimensions (Direct Track only)
                elif page_idx in original_page_sizes:
                    current_target_w, current_target_h = original_page_sizes[page_idx]
                    dimension_source = "original_file"

                # Priority 2: OCR/UnifiedDocument dimensions (which may have been adjusted for orientation)
                elif page_idx in page_dimensions:
                    current_page_dims = page_dimensions[page_idx]
                    current_target_w = float(current_page_dims['width'])
                    current_target_h = float(current_page_dims['height'])
                    dimension_source = "ocr_unified_doc"

                # Priority 3: Fallback to first page
                else:
                    current_target_w = ocr_width
                    current_target_h = ocr_height
                    dimension_source = "fallback_first_page"
                    logger.warning(f"No dimensions for page {page_num}, using first page size")

                # For pages after the first, check if orientation adjustment is needed
                # (First page was already handled above)
                if page_num > 1 and dimension_source == "original_file":
                    # Build per-page data for orientation detection
                    page_ocr_data = {
                        'text_regions': [r for r in text_regions if r.get('page', 1) == page_num],
                        'layout_data': {
                            'elements': [e for e in layout_data.get('elements', [])
                                        if e.get('page', 0) == page_idx]
                        },
                        'images_metadata': [i for i in images_metadata if i.get('page', 0) == page_idx]
                    }
                    needs_page_rotation, adj_w, adj_h = self._detect_content_orientation(
                        current_target_w, current_target_h, page_ocr_data
                    )
                    if needs_page_rotation or (adj_w != current_target_w or adj_h != current_target_h):
                        logger.info(f"第 {page_num} 頁尺寸調整: "
                                   f"{current_target_w:.1f}x{current_target_h:.1f} -> "
                                   f"{adj_w:.1f}x{adj_h:.1f}")
                        current_target_w, current_target_h = adj_w, adj_h

                # Calculate scale factors for coordinate transformation
                # OCR coordinates need to be scaled if original file dimensions differ
                if dimension_source == "original_file":
                    # Get OCR dimensions for this page to calculate scale
                    if page_idx in page_dimensions:
                        ocr_page_w = float(page_dimensions[page_idx]['width'])
                        ocr_page_h = float(page_dimensions[page_idx]['height'])
                    else:
                        ocr_page_w = ocr_width
                        ocr_page_h = ocr_height

                    current_scale_w = current_target_w / ocr_page_w if ocr_page_w > 0 else 1.0
                    current_scale_h = current_target_h / ocr_page_h if ocr_page_h > 0 else 1.0
                else:
                    # Using OCR/UnifiedDocument dimensions directly, no scaling needed
                    current_scale_w = 1.0
                    current_scale_h = 1.0

                logger.info(f"第 {page_num} 頁尺寸: {current_target_w:.1f} x {current_target_h:.1f} "
                           f"(來源: {dimension_source}, 縮放: {current_scale_w:.3f}x{current_scale_h:.3f})")

                if page_num > 1:
                    pdf_canvas.showPage()

                # Set page size for current page
                pdf_canvas.setPageSize((current_target_w, current_target_h))

                # Get regions for this page
                page_text_regions = pages_data.get(page_num, [])
                page_table_regions = [t for t in table_elements if t.get('page') == page_num - 1]
                page_image_regions = [
                    img for img in images_metadata
                    if img.get('page') == page_num - 1
                    and img.get('type') != 'table'
                    and img.get('image_path') is not None  # Skip table placeholders
                ]

                # Draw in layers: images → tables → text

                # 1. Draw images (bottom layer)
                for img_meta in page_image_regions:
                    self.draw_image_region(
                        pdf_canvas, img_meta, current_target_h,
                        json_parent_dir, current_scale_w, current_scale_h
                    )

                # 2. Draw tables (middle layer)
                for table_elem in page_table_regions:
                    self.draw_table_region(
                        pdf_canvas, table_elem, images_metadata,
                        current_target_h, current_scale_w, current_scale_h,
                        result_dir=json_parent_dir
                    )

                # 3. Draw text (top layer)
                for region in page_text_regions:
                    self.draw_text_region(
                        pdf_canvas, region, current_target_h,
                        current_scale_w, current_scale_h
                    )

                logger.info(f"<<< 第 {page_num} 頁完成")

            # Save PDF
            pdf_canvas.save()

            file_size = output_path.stat().st_size
            logger.info(f"Generated PDF: {output_path.name} ({file_size} bytes)")
            return True

        except Exception as e:
            logger.error(f"Failed to generate PDF: {e}")
            import traceback
            traceback.print_exc()
            return False

    def calculate_page_dimensions(self, ocr_data: Dict, source_file_path: Optional[Path] = None) -> Tuple[float, float]:
        """
        從 OCR JSON 數據中取得頁面尺寸。
        優先使用明確的 dimensions 欄位，失敗時才回退到 bbox 推斷。

        Args:
            ocr_data: Complete OCR data dictionary with text_regions and layout
            source_file_path: Optional path to source file (fallback only)

        Returns:
            Tuple of (width, height) in points
        """
        # *** 優先級 1: 檢查 ocr_dimensions (UnifiedDocument 轉換來的) ***
        if 'ocr_dimensions' in ocr_data:
            dims = ocr_data['ocr_dimensions']
            # Handle both dict format {'width': w, 'height': h} and
            # list format [{'page': 1, 'width': w, 'height': h}, ...]
            if isinstance(dims, list) and len(dims) > 0:
                dims = dims[0]  # Use first page dimensions
            if isinstance(dims, dict):
                w = float(dims.get('width', 0))
                h = float(dims.get('height', 0))
                if w > 0 and h > 0:
                    logger.info(f"使用 ocr_dimensions 欄位的頁面尺寸: {w:.1f} x {h:.1f}")
                    return (w, h)

        # *** 優先級 2: 檢查原始 JSON 的 dimensions ***
        if 'dimensions' in ocr_data:
            dims = ocr_data['dimensions']
            w = float(dims.get('width', 0))
            h = float(dims.get('height', 0))
            if w > 0 and h > 0:
                logger.info(f"使用 dimensions 欄位的頁面尺寸: {w:.1f} x {h:.1f}")
                return (w, h)

        # *** 優先級 3: Fallback - 從 bbox 推斷 (僅當上述皆缺失時使用) ***
        logger.info("dimensions 欄位不可用，回退到 bbox 推斷")
        max_x = 0
        max_y = 0

        # *** 關鍵修復：檢查所有可能包含 bbox 的字段 ***
        # 不同版本的 OCR 輸出可能使用不同的字段名
        all_regions = []

        # 1. text_regions - 包含所有文字區域（最常見）
        if 'text_regions' in ocr_data and isinstance(ocr_data['text_regions'], list):
            all_regions.extend(ocr_data['text_regions'])

        # 2. image_regions - 包含圖片區域
        if 'image_regions' in ocr_data and isinstance(ocr_data['image_regions'], list):
            all_regions.extend(ocr_data['image_regions'])

        # 3. tables - 包含表格區域
        if 'tables' in ocr_data and isinstance(ocr_data['tables'], list):
            all_regions.extend(ocr_data['tables'])

        # 4. layout - 可能包含布局信息（可能是空列表）
        if 'layout' in ocr_data and isinstance(ocr_data['layout'], list):
            all_regions.extend(ocr_data['layout'])

        # 5. layout_data.elements - PP-StructureV3 格式
        if 'layout_data' in ocr_data and isinstance(ocr_data['layout_data'], dict):
            elements = ocr_data['layout_data'].get('elements', [])
            if elements:
                all_regions.extend(elements)

        if not all_regions:
            # 如果 JSON 為空，回退到原始檔案尺寸
            logger.warning("JSON 中沒有找到 text_regions, image_regions, tables, layout 或 layout_data.elements，回退到原始檔案尺寸。")
            if source_file_path:
                dims = self.get_original_page_size(source_file_path)
                if dims:
                    return dims
            return A4

        region_count = 0
        for region in all_regions:
            try:
                bbox = region.get('bbox')
                if not bbox:
                    continue

                region_count += 1

                # *** 關鍵修復：正確處理多邊形 [[x, y], ...] 格式 ***
                if isinstance(bbox[0], (int, float)):
                    # 處理簡單的 [x1, y1, x2, y2] 格式
                    max_x = max(max_x, bbox[2])
                    max_y = max(max_y, bbox[3])
                elif isinstance(bbox[0], (list, tuple)):
                    # 處理多邊形 [[x, y], ...] 格式
                    x_coords = [p[0] for p in bbox if isinstance(p, (list, tuple)) and len(p) >= 2]
                    y_coords = [p[1] for p in bbox if isinstance(p, (list, tuple)) and len(p) >= 2]
                    if x_coords and y_coords:
                        max_x = max(max_x, max(x_coords))
                        max_y = max(max_y, max(y_coords))

            except Exception as e:
                logger.warning(f"Error processing bbox {bbox}: {e}")

        if max_x > 0 and max_y > 0:
            logger.info(f"從 {region_count} 個區域中推斷出的 OCR 座標系尺寸: {max_x:.1f} x {max_y:.1f}")
            return (max_x, max_y)
        else:
            # 如果所有 bbox 都解析失敗，才回退
            logger.warning("無法從 bbox 推斷尺寸，回退到原始檔案尺寸。")
            if source_file_path:
                dims = self.get_original_page_size(source_file_path)
                if dims:
                    return dims
            return A4

    def get_all_page_sizes(self, file_path: Path) -> Dict[int, Tuple[float, float]]:
        """
        Extract dimensions for all pages from original source file

        Args:
            file_path: Path to original file (image or PDF)

        Returns:
            Dict mapping page index (0-based) to (width, height) in points
            Empty dict if extraction fails
        """
        page_sizes = {}

        try:
            if not file_path.exists():
                logger.warning(f"File not found: {file_path}")
                return page_sizes

            # For images, single page with dimensions from PIL
            if file_path.suffix.lower() in ['.png', '.jpg', '.jpeg', '.bmp', '.tiff']:
                img = Image.open(file_path)
                # Use pixel dimensions directly as points (1:1 mapping)
                # This matches how PaddleOCR reports coordinates
                width_pt = float(img.width)
                height_pt = float(img.height)
                page_sizes[0] = (width_pt, height_pt)
                logger.info(f"Extracted dimensions from image: {width_pt:.1f} x {height_pt:.1f} points (1:1 pixel mapping)")
                return page_sizes

            # For PDFs, extract dimensions for all pages using PyPDF2
            if file_path.suffix.lower() == '.pdf':
                try:
                    from PyPDF2 import PdfReader
                    reader = PdfReader(file_path)
                    total_pages = len(reader.pages)

                    for page_idx in range(total_pages):
                        page = reader.pages[page_idx]
                        # MediaBox gives [x1, y1, x2, y2] in points
                        mediabox = page.mediabox
                        width_pt = float(mediabox.width)
                        height_pt = float(mediabox.height)

                        # IMPORTANT: Consider page rotation!
                        # PDF pages can have /Rotate attribute (0, 90, 180, 270)
                        # When rotation is 90 or 270 degrees, width and height should be swapped
                        # because pdf2image and PDF viewers apply this rotation when rendering
                        rotation = page.get('/Rotate', 0)
                        if rotation is None:
                            rotation = 0
                        rotation = int(rotation) % 360

                        if rotation in (90, 270):
                            # Swap width and height for 90/270 degree rotation
                            width_pt, height_pt = height_pt, width_pt
                            logger.info(f"Page {page_idx}: Rotation={rotation}°, swapped dimensions to {width_pt:.1f} x {height_pt:.1f}")

                        page_sizes[page_idx] = (width_pt, height_pt)

                    logger.info(f"Extracted dimensions from PDF: {total_pages} pages")
                    for idx, (w, h) in page_sizes.items():
                        logger.debug(f"  Page {idx}: {w:.1f} x {h:.1f} points")

                    return page_sizes

                except ImportError:
                    logger.warning("PyPDF2 not available, cannot extract PDF dimensions")
                except Exception as e:
                    logger.warning(f"Failed to extract PDF dimensions: {e}")

        except Exception as e:
            logger.warning(f"Failed to get page sizes from {file_path}: {e}")

        return page_sizes

    def get_original_page_size(self, file_path: Path) -> Optional[Tuple[float, float]]:
        """
        Extract first page dimensions from original source file (backward compatibility)

        Args:
            file_path: Path to original file (image or PDF)

        Returns:
            Tuple of (width, height) in points or None
        """
        page_sizes = self.get_all_page_sizes(file_path)
        if 0 in page_sizes:
            return page_sizes[0]
        return None

    def _get_bbox_coords(self, bbox: Union[Dict, List[List[float]], List[float]]) -> Optional[Tuple[float, float, float, float]]:
        """將任何 bbox 格式 (dict, 多邊形或 [x1,y1,x2,y2]) 轉換為 [x_min, y_min, x_max, y_max]. Uses shared bbox utility."""
        return normalize_bbox(bbox)

    def _is_bbox_inside(self, inner_bbox_data: Dict, outer_bbox_data: Dict, tolerance: float = 5.0) -> bool:
        """
        檢查 'inner_bbox' 是否在 'outer_bbox' 內部（帶有容錯）。
        此版本可處理多邊形和矩形。
        """
        inner_coords = self._get_bbox_coords(inner_bbox_data.get('bbox'))
        outer_coords = self._get_bbox_coords(outer_bbox_data.get('bbox'))

        if not inner_coords or not outer_coords:
            return False

        inner_x1, inner_y1, inner_x2, inner_y2 = inner_coords
        outer_x1, outer_y1, outer_x2, outer_y2 = outer_coords

        # 檢查 inner 是否在 outer 內部 (加入 tolerance)
        is_inside = (
            (inner_x1 >= outer_x1 - tolerance) and
            (inner_y1 >= outer_y1 - tolerance) and
            (inner_x2 <= outer_x2 + tolerance) and
            (inner_y2 <= outer_y2 + tolerance)
        )
        return is_inside

    def _bbox_overlaps(self, bbox1_data: Dict, bbox2_data: Dict, tolerance: float = 5.0) -> bool:
        """
        檢查兩個 bbox 是否有重疊（帶有容錯）。
        如果有任何重疊，返回 True。

        Args:
            bbox1_data: 第一個 bbox 數據
            bbox2_data: 第二個 bbox 數據
            tolerance: 容錯值（像素）

        Returns:
            True 如果兩個 bbox 有重疊
        """
        coords1 = self._get_bbox_coords(bbox1_data.get('bbox'))
        coords2 = self._get_bbox_coords(bbox2_data.get('bbox'))

        if not coords1 or not coords2:
            return False

        x1_min, y1_min, x1_max, y1_max = coords1
        x2_min, y2_min, x2_max, y2_max = coords2

        # 擴展 bbox2（表格/圖片區域）的範圍
        x2_min -= tolerance
        y2_min -= tolerance
        x2_max += tolerance
        y2_max += tolerance

        # 檢查是否有重疊：如果沒有重疊，則必定滿足以下條件之一
        no_overlap = (
            x1_max < x2_min or  # bbox1 在 bbox2 左側
            x1_min > x2_max or  # bbox1 在 bbox2 右側
            y1_max < y2_min or  # bbox1 在 bbox2 上方
            y1_min > y2_max     # bbox1 在 bbox2 下方
        )

        return not no_overlap

    def _calculate_overlap_ratio(self, text_bbox_data: Dict, avoid_bbox_data: Dict) -> float:
        """
        計算文字區域與避免區域的重疊比例。

        Args:
            text_bbox_data: 文字區域 bbox 數據
            avoid_bbox_data: 避免區域 bbox 數據

        Returns:
            重疊面積佔文字區域面積的比例 (0.0 - 1.0)
        """
        text_coords = self._get_bbox_coords(text_bbox_data.get('bbox'))
        avoid_coords = self._get_bbox_coords(avoid_bbox_data.get('bbox'))

        if not text_coords or not avoid_coords:
            return 0.0

        tx0, ty0, tx1, ty1 = text_coords
        ax0, ay0, ax1, ay1 = avoid_coords

        # Calculate text area
        text_area = (tx1 - tx0) * (ty1 - ty0)
        if text_area <= 0:
            return 0.0

        # Calculate intersection
        inter_x0 = max(tx0, ax0)
        inter_y0 = max(ty0, ay0)
        inter_x1 = min(tx1, ax1)
        inter_y1 = min(ty1, ay1)

        # Check if there's actual intersection
        if inter_x1 <= inter_x0 or inter_y1 <= inter_y0:
            return 0.0

        inter_area = (inter_x1 - inter_x0) * (inter_y1 - inter_y0)
        return inter_area / text_area

    def _filter_text_in_regions(self, text_regions: List[Dict], regions_to_avoid: List[Dict], overlap_threshold: float = 0.5) -> List[Dict]:
        """
        過濾掉與 'regions_to_avoid'（例如表格、圖片）顯著重疊的文字區域。

        使用重疊比例閾值來判斷是否過濾，避免過濾掉僅相鄰但不重疊的文字。

        Args:
            text_regions: 文字區域列表
            regions_to_avoid: 需要避免的區域列表（表格、圖片）
            overlap_threshold: 重疊比例閾值 (0.0-1.0)，只有當文字區域
                              與避免區域的重疊比例超過此閾值時才會被過濾
                              預設 0.5 表示超過 50% 重疊才過濾

        Returns:
            過濾後的文字區域列表
        """
        filtered_text = []
        filtered_count = 0

        for text_region in text_regions:
            should_filter = False
            max_overlap = 0.0

            for avoid_region in regions_to_avoid:
                # 計算重疊比例
                overlap_ratio = self._calculate_overlap_ratio(text_region, avoid_region)
                max_overlap = max(max_overlap, overlap_ratio)

                # 只有當重疊比例超過閾值時才過濾
                if overlap_ratio > overlap_threshold:
                    should_filter = True
                    filtered_count += 1
                    logger.debug(f"過濾掉重疊文字 (重疊比例: {overlap_ratio:.1%}): {text_region.get('text', '')[:30]}...")
                    break

            if not should_filter:
                filtered_text.append(text_region)
                if max_overlap > 0:
                    logger.debug(f"保留文字 (最大重疊比例: {max_overlap:.1%}): {text_region.get('text', '')[:30]}...")

        logger.info(f"原始文字區域: {len(text_regions)}, 過濾後: {len(filtered_text)}, 移除: {filtered_count}")
        return filtered_text

    def draw_text_region(
        self,
        pdf_canvas: canvas.Canvas,
        region: Dict,
        page_height: float,
        scale_w: float = 1.0,
        scale_h: float = 1.0
    ):
        """
        Draw a text region at precise coordinates

        Args:
            pdf_canvas: ReportLab canvas object
            region: Text region dict with text, bbox, confidence
            page_height: Height of page (for coordinate transformation)
            scale_w: Scale factor for X coordinates (PDF width / OCR width)
            scale_h: Scale factor for Y coordinates (PDF height / OCR height)
        """
        text = region.get('text', '')
        bbox = region.get('bbox', [])
        confidence = region.get('confidence', 1.0)

        if not text or not bbox:
            return

        try:
            # Handle different bbox formats
            if isinstance(bbox, dict):
                # Dict format from UnifiedDocument: {"x0": ..., "y0": ..., "x1": ..., "y1": ...}
                if 'x0' in bbox and 'y0' in bbox and 'x1' in bbox and 'y1' in bbox:
                    ocr_x_left = float(bbox['x0'])
                    ocr_y_top = float(bbox['y0'])
                    ocr_x_right = float(bbox['x1'])
                    ocr_y_bottom = float(bbox['y1'])
                else:
                    logger.warning(f"Dict bbox missing required keys: {bbox}")
                    return
            elif isinstance(bbox, list):
                if len(bbox) < 4:
                    return
                # Polygon format [[x,y], [x,y], [x,y], [x,y]] (4 points)
                if isinstance(bbox[0], list):
                    ocr_x_left = bbox[0][0]    # Left X
                    ocr_y_top = bbox[0][1]     # Top Y in OCR coordinates
                    ocr_x_right = bbox[2][0]   # Right X
                    ocr_y_bottom = bbox[2][1]  # Bottom Y in OCR coordinates
                # Simple list format [x0, y0, x1, y1]
                elif isinstance(bbox[0], (int, float)):
                    ocr_x_left = bbox[0]
                    ocr_y_top = bbox[1]
                    ocr_x_right = bbox[2]
                    ocr_y_bottom = bbox[3]
                else:
                    logger.warning(f"Unexpected bbox list format: {bbox}")
                    return
            else:
                logger.warning(f"Invalid bbox format: {bbox}")
                return

            logger.info(f"[文字] '{text[:20]}...' OCR原始座標: L={ocr_x_left:.0f}, T={ocr_y_top:.0f}, R={ocr_x_right:.0f}, B={ocr_y_bottom:.0f}")

            # Apply scale factors to convert from OCR space to PDF space
            scaled_x_left = ocr_x_left * scale_w
            scaled_y_top = ocr_y_top * scale_h
            scaled_x_right = ocr_x_right * scale_w
            scaled_y_bottom = ocr_y_bottom * scale_h

            logger.info(f"[文字] '{text[:20]}...' 縮放後(scale={scale_w:.3f},{scale_h:.3f}): L={scaled_x_left:.1f}, T={scaled_y_top:.1f}, R={scaled_x_right:.1f}, B={scaled_y_bottom:.1f}")

            # Calculate bbox dimensions (after scaling)
            bbox_width = abs(scaled_x_right - scaled_x_left)
            bbox_height = abs(scaled_y_bottom - scaled_y_top)

            # Calculate font size using heuristics
            # For multi-line text, divide bbox height by number of lines
            lines = text.split('\n')
            non_empty_lines = [l for l in lines if l.strip()]
            num_lines = max(len(non_empty_lines), 1)

            # Font size calculation with stabilization
            # Use 0.8 factor to leave room for line spacing
            raw_font_size = (bbox_height / num_lines) * 0.8

            # Stabilize font size for body text (most common case)
            # Normal body text should be 9-11pt, only deviate for clear outliers
            element_type = region.get('element_type', 'text')
            if element_type in ('text', 'paragraph'):
                # For body text, bias toward 10pt baseline
                if 7 <= raw_font_size <= 14:
                    # Near-normal range: use weighted average toward 10pt
                    font_size = raw_font_size * 0.7 + 10 * 0.3
                else:
                    # Clear outlier: use raw but clamp more aggressively
                    font_size = max(min(raw_font_size, 14), 7)
            else:
                # For titles/headers/etc, use raw calculation with wider range
                font_size = max(min(raw_font_size, 72), 4)

            logger.debug(f"Text has {num_lines} non-empty lines, bbox_height={bbox_height:.1f}, raw={raw_font_size:.1f}, final={font_size:.1f}")

            # Transform coordinates: OCR (top-left origin) → PDF (bottom-left origin)
            # CRITICAL: Y-axis flip!
            # For multi-line text, start from TOP of bbox and go downward
            pdf_x = scaled_x_left
            pdf_y_top = page_height - scaled_y_top  # Top of bbox in PDF coordinates
            # Adjust for font baseline: first line starts below the top edge
            pdf_y = pdf_y_top - font_size  # Start first line one font size below top

            logger.info(f"[文字] '{text[:30]}' → PDF位置: ({pdf_x:.1f}, {pdf_y:.1f}), 字體:{font_size:.1f}pt, 寬x高:{bbox_width:.0f}x{bbox_height:.0f}, 行數:{num_lines}")

            # Set font with track-specific styling
            style_info = region.get('style')
            element_type = region.get('element_type', 'text')
            is_direct_track = (self.current_processing_track == ProcessingTrack.DIRECT or
                               self.current_processing_track == ProcessingTrack.HYBRID)

            if style_info and is_direct_track:
                # Direct track: Apply rich styling from StyleInfo
                self._apply_text_style(pdf_canvas, style_info, default_size=font_size)
                # Get current font for width calculation
                font_name = pdf_canvas._fontname
                font_size = pdf_canvas._fontsize
                logger.debug(f"Applied Direct track style: font={font_name}, size={font_size}")
            else:
                # OCR track or no style: Use simple font selection with element-type based styling
                font_name = self.font_name if self.font_registered else 'Helvetica'

                # Apply element-type specific styling (for OCR track)
                if element_type == 'title':
                    # Titles: use larger, bold font
                    font_size = min(font_size * 1.3, 36)  # 30% larger, max 36pt
                    pdf_canvas.setFont(font_name, font_size)
                    logger.debug(f"Applied title style: size={font_size:.1f}")
                elif element_type == 'header':
                    # Headers: slightly larger
                    font_size = min(font_size * 1.15, 24)  # 15% larger, max 24pt
                    pdf_canvas.setFont(font_name, font_size)
                elif element_type == 'caption':
                    # Captions: slightly smaller, italic if available
                    font_size = max(font_size * 0.9, 6)  # 10% smaller, min 6pt
                    pdf_canvas.setFont(font_name, font_size)
                else:
                    pdf_canvas.setFont(font_name, font_size)

            # Handle line breaks (split text by newlines)
            # OCR track: simple left-aligned rendering
            # Note: non_empty_lines was already calculated above for font sizing
            line_height = font_size * 1.2  # 120% of font size for line spacing

            # Draw each non-empty line (using proper line index for positioning)
            for i, line in enumerate(non_empty_lines):
                line_y = pdf_y - (i * line_height)

                # Calculate text width to prevent overflow
                text_width = pdf_canvas.stringWidth(line, font_name, font_size)

                # If text is too wide for bbox, scale down font for this line
                current_font_size = font_size
                if text_width > bbox_width:
                    scale_factor = bbox_width / text_width
                    current_font_size = font_size * scale_factor * 0.95  # 95% to add small margin
                    current_font_size = max(current_font_size, 3)  # Minimum 3pt
                    pdf_canvas.setFont(font_name, current_font_size)

                # Draw text at left-aligned position (OCR track uses simple left alignment)
                pdf_canvas.drawString(pdf_x, line_y, line)

                # Reset font size for next line
                if text_width > bbox_width:
                    pdf_canvas.setFont(font_name, font_size)

            # Debug: Draw bounding box (optional)
            if settings.pdf_enable_bbox_debug:
                pdf_canvas.setStrokeColorRGB(1, 0, 0, 0.3)  # Red, semi-transparent
                pdf_canvas.setLineWidth(0.5)
                # Use already-extracted coordinates (works for all bbox formats)
                # Draw rectangle using the scaled coordinates
                pdf_x1 = ocr_x_left * scale_w
                pdf_y1 = page_height - ocr_y_top * scale_h
                pdf_x2 = ocr_x_right * scale_w
                pdf_y2 = page_height - ocr_y_bottom * scale_h
                # Draw bbox rectangle
                pdf_canvas.line(pdf_x1, pdf_y1, pdf_x2, pdf_y1)  # top
                pdf_canvas.line(pdf_x2, pdf_y1, pdf_x2, pdf_y2)  # right
                pdf_canvas.line(pdf_x2, pdf_y2, pdf_x1, pdf_y2)  # bottom
                pdf_canvas.line(pdf_x1, pdf_y2, pdf_x1, pdf_y1)  # left

        except Exception as e:
            logger.warning(f"Failed to draw text region '{text[:20]}...': {e}")

    def _compute_table_grid_from_cell_boxes(
        self,
        cell_boxes: List[List[float]],
        table_bbox: List[float],
        num_rows: int,
        num_cols: int
    ) -> Tuple[Optional[List[float]], Optional[List[float]]]:
        """
        Compute column widths and row heights from cell bounding boxes.

        This uses the cell boxes extracted by SLANeXt to calculate the actual
        column widths and row heights, which provides more accurate table rendering
        than uniform distribution.

        Args:
            cell_boxes: List of cell bboxes [[x1,y1,x2,y2], ...]
            table_bbox: Table bounding box [x1,y1,x2,y2]
            num_rows: Number of rows in the table
            num_cols: Number of columns in the table

        Returns:
            Tuple of (col_widths, row_heights) or (None, None) if calculation fails
        """
        if not cell_boxes or len(cell_boxes) < 2:
            return None, None

        try:
            table_x1, table_y1, table_x2, table_y2 = table_bbox
            table_width = table_x2 - table_x1
            table_height = table_y2 - table_y1

            # Collect all unique X and Y boundaries from cell boxes
            x_boundaries = set()
            y_boundaries = set()

            for box in cell_boxes:
                if len(box) >= 4:
                    x1, y1, x2, y2 = box[:4]
                    # Convert to relative coordinates within table
                    x_boundaries.add(x1 - table_x1)
                    x_boundaries.add(x2 - table_x1)
                    y_boundaries.add(y1 - table_y1)
                    y_boundaries.add(y2 - table_y1)

            # Sort boundaries
            x_boundaries = sorted(x_boundaries)
            y_boundaries = sorted(y_boundaries)

            # Ensure we have boundaries at table edges
            if x_boundaries and x_boundaries[0] > 5:
                x_boundaries.insert(0, 0)
            if x_boundaries and x_boundaries[-1] < table_width - 5:
                x_boundaries.append(table_width)

            if y_boundaries and y_boundaries[0] > 5:
                y_boundaries.insert(0, 0)
            if y_boundaries and y_boundaries[-1] < table_height - 5:
                y_boundaries.append(table_height)

            # Calculate column widths from X boundaries
            # Merge boundaries that are too close (< 5px)
            merged_x = [x_boundaries[0]] if x_boundaries else []
            for x in x_boundaries[1:]:
                if x - merged_x[-1] > 5:
                    merged_x.append(x)
            x_boundaries = merged_x

            # Calculate row heights from Y boundaries
            merged_y = [y_boundaries[0]] if y_boundaries else []
            for y in y_boundaries[1:]:
                if y - merged_y[-1] > 5:
                    merged_y.append(y)
            y_boundaries = merged_y

            # Calculate widths and heights
            col_widths = []
            for i in range(len(x_boundaries) - 1):
                col_widths.append(x_boundaries[i + 1] - x_boundaries[i])

            row_heights = []
            for i in range(len(y_boundaries) - 1):
                row_heights.append(y_boundaries[i + 1] - y_boundaries[i])

            # Validate: number of columns/rows should match expected
            if len(col_widths) == num_cols and len(row_heights) == num_rows:
                logger.info(f"[TABLE] Cell boxes grid: {num_cols} cols, {num_rows} rows")
                logger.debug(f"[TABLE] Col widths from cell_boxes: {[f'{w:.1f}' for w in col_widths]}")
                logger.debug(f"[TABLE] Row heights from cell_boxes: {[f'{h:.1f}' for h in row_heights]}")
                return col_widths, row_heights
            else:
                # Grid doesn't match, might be due to merged cells
                logger.debug(
                    f"[TABLE] Cell boxes grid mismatch: "
                    f"got {len(col_widths)}x{len(row_heights)}, expected {num_cols}x{num_rows}"
                )
                # Still return the widths/heights if counts are close
                if abs(len(col_widths) - num_cols) <= 1 and abs(len(row_heights) - num_rows) <= 1:
                    # Adjust to match expected count
                    while len(col_widths) < num_cols:
                        col_widths.append(col_widths[-1] if col_widths else table_width / num_cols)
                    while len(col_widths) > num_cols:
                        col_widths.pop()
                    while len(row_heights) < num_rows:
                        row_heights.append(row_heights[-1] if row_heights else table_height / num_rows)
                    while len(row_heights) > num_rows:
                        row_heights.pop()
                    return col_widths, row_heights

                return None, None

        except Exception as e:
            logger.warning(f"[TABLE] Failed to compute grid from cell boxes: {e}")
            return None, None

    def draw_table_region(
        self,
        pdf_canvas: canvas.Canvas,
        table_element: Dict,
        images_metadata: List[Dict],
        page_height: float,
        scale_w: float = 1.0,
        scale_h: float = 1.0,
        result_dir: Optional[Path] = None
    ):
        """
        Draw a table region by parsing HTML and rebuilding with ReportLab Table

        Args:
            pdf_canvas: ReportLab canvas object
            table_element: Table element dict with HTML content
            images_metadata: List of image metadata to find table bbox
            page_height: Height of page
            scale_w: Scale factor for X coordinates (PDF width / OCR width)
            scale_h: Scale factor for Y coordinates (PDF height / OCR height)
            result_dir: Directory containing result files (for embedded images)
        """
        try:
            elem_id = table_element.get('element_id', 'unknown')
            use_border_only = table_element.get('_use_border_only', False)
            logger.info(f"[DEBUG] draw_table_region: elem_id={elem_id}, _use_border_only={use_border_only}")

            html_content = table_element.get('content', '')
            if not html_content:
                # Even without HTML, draw border if requested
                if use_border_only:
                    self._draw_table_border_only(pdf_canvas, table_element, page_height, scale_w, scale_h)
                return

            # Apply column correction if enabled
            cell_boxes = table_element.get('cell_boxes', [])
            if (settings.table_column_correction_enabled and
                TABLE_COLUMN_CORRECTOR_AVAILABLE and
                cell_boxes):
                try:
                    corrector = TableColumnCorrector(
                        correction_threshold=settings.table_column_correction_threshold,
                        vertical_merge_enabled=settings.vertical_fragment_merge_enabled,
                        vertical_aspect_ratio=settings.vertical_fragment_aspect_ratio
                    )
                    # Get table bbox for vertical fragment detection
                    table_bbox = table_element.get('bbox', [])
                    if isinstance(table_bbox, dict):
                        table_bbox = [table_bbox['x0'], table_bbox['y0'], table_bbox['x1'], table_bbox['y1']]

                    corrected_html, stats = corrector.correct(
                        html=html_content,
                        cell_boxes=cell_boxes,
                        table_bbox=table_bbox if isinstance(table_bbox, list) and len(table_bbox) >= 4 else None
                    )
                    if stats.get('column_corrections', 0) > 0:
                        logger.info(f"[TABLE] {elem_id}: Column correction applied - {stats}")
                        html_content = corrected_html
                except Exception as e:
                    logger.warning(f"[TABLE] {elem_id}: Column correction failed: {e}, using original HTML")

            # Parse HTML first to get table structure for grid validation
            parser = HTMLTableParser()
            parser.feed(html_content)

            if not parser.tables:
                logger.warning("No tables found in HTML content")
                return

            # Get the first table (PP-StructureV3 usually provides one table per element)
            table_data = parser.tables[0]
            rows = table_data['rows']

            if not rows:
                return

            # Calculate number of rows and columns from HTML for grid validation
            num_rows = len(rows)
            max_cols = 0
            for row in rows:
                row_cols = sum(cell.get('colspan', 1) for cell in row['cells'])
                max_cols = max(max_cols, row_cols)

            # Check if table was rebuilt - if so, use HTML content directly
            was_rebuilt = table_element.get('was_rebuilt', False)
            cell_boxes_rendered = False  # Track if we rendered borders with cell_boxes

            if was_rebuilt:
                logger.info(f"[TABLE] {elem_id}: Table was rebuilt, using HTML content directly")
            elif use_border_only:
                # Bad quality cell_boxes: skip cell_boxes rendering, use ReportLab Table with borders
                logger.info(f"[TABLE] {elem_id}: Bad cell_boxes quality, using ReportLab Table with borders")
            else:
                # Check if cell_boxes can produce a valid grid before rendering borders
                cell_boxes = table_element.get('cell_boxes', [])
                if cell_boxes:
                    # Get table bbox for grid calculation
                    temp_bbox = table_element.get('bbox', [])
                    if isinstance(temp_bbox, dict):
                        raw_bbox = [temp_bbox['x0'], temp_bbox['y0'], temp_bbox['x1'], temp_bbox['y1']]
                    elif isinstance(temp_bbox, list) and len(temp_bbox) >= 4:
                        if isinstance(temp_bbox[0], (int, float)):
                            raw_bbox = temp_bbox[:4]
                        else:
                            raw_bbox = [temp_bbox[0][0], temp_bbox[0][1], temp_bbox[2][0], temp_bbox[2][1]]
                    else:
                        raw_bbox = None

                    # Pre-check: can we compute a valid grid from cell_boxes?
                    if raw_bbox:
                        test_col_widths, test_row_heights = self._compute_table_grid_from_cell_boxes(
                            cell_boxes, raw_bbox, num_rows, max_cols
                        )
                        grid_valid = test_col_widths is not None and test_row_heights is not None

                        if grid_valid:
                            logger.info(f"[TABLE] Grid validation passed, rendering borders with cell_boxes")
                            success = self._draw_table_with_cell_boxes(
                                pdf_canvas, table_element, page_height,
                                scale_w, scale_h, result_dir
                            )
                            if success:
                                cell_boxes_rendered = True
                                logger.info("[TABLE] cell_boxes rendered borders, continuing with text-only ReportLab Table")
                            else:
                                logger.info("[TABLE] cell_boxes rendering failed, using ReportLab Table with borders")
                        else:
                            logger.info(f"[TABLE] Grid validation failed (mismatch), using ReportLab Table with borders")
                    else:
                        logger.info("[TABLE] No valid bbox for grid validation, using ReportLab Table with borders")

            # Get bbox directly from table element
            table_bbox = table_element.get('bbox')

            # If no bbox directly, check for bbox_polygon
            if not table_bbox:
                bbox_polygon = table_element.get('bbox_polygon')
                if bbox_polygon and len(bbox_polygon) >= 4:
                    # Convert polygon format to simple bbox [x0, y0, x1, y1]
                    table_bbox = [
                        bbox_polygon[0][0],  # x0
                        bbox_polygon[0][1],  # y0
                        bbox_polygon[2][0],  # x1
                        bbox_polygon[2][1]   # y1
                    ]

            if not table_bbox:
                logger.warning(f"No bbox found for table element")
                return

            # Handle different bbox formats
            if isinstance(table_bbox, dict):
                # Dict format from UnifiedDocument: {"x0": ..., "y0": ..., "x1": ..., "y1": ...}
                if 'x0' in table_bbox and 'y0' in table_bbox and 'x1' in table_bbox and 'y1' in table_bbox:
                    ocr_x_left_raw = float(table_bbox['x0'])
                    ocr_y_top_raw = float(table_bbox['y0'])
                    ocr_x_right_raw = float(table_bbox['x1'])
                    ocr_y_bottom_raw = float(table_bbox['y1'])
                else:
                    logger.error(f"Dict bbox missing required keys (x0, y0, x1, y1): {table_bbox}")
                    return
            elif isinstance(table_bbox, list) and len(table_bbox) == 4:
                # Simple bbox format [x0, y0, x1, y1]
                if isinstance(table_bbox[0], (int, float)):
                    ocr_x_left_raw = table_bbox[0]
                    ocr_y_top_raw = table_bbox[1]
                    ocr_x_right_raw = table_bbox[2]
                    ocr_y_bottom_raw = table_bbox[3]
                # Polygon format [[x,y], [x,y], [x,y], [x,y]]
                elif isinstance(table_bbox[0], list):
                    ocr_x_left_raw = table_bbox[0][0]
                    ocr_y_top_raw = table_bbox[0][1]
                    ocr_x_right_raw = table_bbox[2][0]
                    ocr_y_bottom_raw = table_bbox[2][1]
                else:
                    logger.error(f"Unexpected bbox format: {table_bbox}")
                    return
            else:
                logger.error(f"Invalid table_bbox format: {table_bbox}")
                return

            logger.info(f"[表格] OCR原始座標: L={ocr_x_left_raw:.0f}, T={ocr_y_top_raw:.0f}, R={ocr_x_right_raw:.0f}, B={ocr_y_bottom_raw:.0f}")

            # Apply scaling
            ocr_x_left = ocr_x_left_raw * scale_w
            ocr_y_top = ocr_y_top_raw * scale_h
            ocr_x_right = ocr_x_right_raw * scale_w
            ocr_y_bottom = ocr_y_bottom_raw * scale_h

            table_width = abs(ocr_x_right - ocr_x_left)
            table_height = abs(ocr_y_bottom - ocr_y_top)

            # Transform coordinates
            pdf_x = ocr_x_left
            pdf_y = page_height - ocr_y_bottom

            # Build table data for ReportLab with proper colspan/rowspan handling
            # num_rows and max_cols already calculated above for grid validation
            logger.info(f"[表格] {num_rows}行x{max_cols}列 → PDF位置: ({pdf_x:.1f}, {pdf_y:.1f}), 寬x高: {table_width:.0f}x{table_height:.0f}")

            # Create a grid to track occupied cells (for rowspan handling)
            # occupied[row][col] = True if cell is occupied by a span from above
            occupied = [[False] * max_cols for _ in range(num_rows)]

            # Build the 2D data array and collect span commands
            reportlab_data = []
            span_commands = []

            for row_idx, row in enumerate(rows):
                row_data = [''] * max_cols
                col_idx = 0

                for cell in row['cells']:
                    # Skip occupied cells (from rowspan above)
                    while col_idx < max_cols and occupied[row_idx][col_idx]:
                        col_idx += 1

                    if col_idx >= max_cols:
                        break

                    text = cell['text'].strip()
                    colspan = cell.get('colspan', 1)
                    rowspan = cell.get('rowspan', 1)

                    # Place text in the top-left cell of the span
                    row_data[col_idx] = text

                    # Mark cells as occupied for rowspan
                    for r in range(row_idx, min(row_idx + rowspan, num_rows)):
                        for c in range(col_idx, min(col_idx + colspan, max_cols)):
                            if r > row_idx or c > col_idx:
                                occupied[r][c] = True

                    # Add SPAN command if cell spans multiple rows/cols
                    if colspan > 1 or rowspan > 1:
                        span_end_col = min(col_idx + colspan - 1, max_cols - 1)
                        span_end_row = min(row_idx + rowspan - 1, num_rows - 1)
                        span_commands.append(('SPAN', (col_idx, row_idx), (span_end_col, span_end_row)))

                    col_idx += colspan

                reportlab_data.append(row_data)

            # Calculate column widths and row heights
            # First, try to use cell_boxes if available for more accurate layout
            cell_boxes = table_element.get('cell_boxes')
            raw_table_bbox = [ocr_x_left_raw, ocr_y_top_raw, ocr_x_right_raw, ocr_y_bottom_raw]

            computed_col_widths = None
            computed_row_heights = None

            if cell_boxes:
                cell_boxes_source = table_element.get('cell_boxes_source', 'unknown')
                logger.info(f"[TABLE] Using {len(cell_boxes)} cell boxes from {cell_boxes_source}")
                computed_col_widths, computed_row_heights = self._compute_table_grid_from_cell_boxes(
                    cell_boxes, raw_table_bbox, num_rows, max_cols
                )

            # Use computed widths if available, otherwise fall back to equal distribution
            if computed_col_widths:
                # Scale col_widths to PDF coordinates
                col_widths = [w * scale_w for w in computed_col_widths]
                logger.info(f"[TABLE] Using cell_boxes col widths (scaled)")
            else:
                col_widths = [table_width / max_cols] * max_cols
                logger.info(f"[TABLE] Using equal distribution col widths: {table_width/max_cols:.1f} each")

            # Row heights - ALWAYS use to ensure table fits bbox properly
            # Use computed heights from cell_boxes, or uniform distribution as fallback
            if computed_row_heights:
                # Scale row_heights to PDF coordinates
                row_heights = [h * scale_h for h in computed_row_heights]
                logger.info(f"[TABLE] Using cell_boxes row heights (scaled)")
            else:
                # Uniform distribution based on table bbox - ensures table fills its allocated space
                row_heights = [table_height / num_rows] * num_rows
                logger.info(f"[TABLE] Using uniform row heights: {table_height/num_rows:.1f} each")

            # Create ReportLab Table
            # Use smaller font to fit content with auto-wrap
            font_size = 8  # Fixed reasonable font size for table content

            # Create paragraph style for text wrapping in cells
            cell_style = ParagraphStyle(
                'CellStyle',
                fontName=self.font_name if self.font_registered else 'Helvetica',
                fontSize=font_size,
                leading=font_size * 1.2,
                alignment=TA_CENTER,
                wordWrap='CJK',  # Better wrapping for Chinese text
            )

            # Convert text to Paragraph objects for auto-wrapping
            for row_idx, row_data in enumerate(reportlab_data):
                for col_idx, cell_text in enumerate(row_data):
                    if cell_text:
                        # Escape HTML special characters and create Paragraph
                        escaped_text = cell_text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
                        reportlab_data[row_idx][col_idx] = Paragraph(escaped_text, cell_style)

            # Create table with col widths and row heights
            # Always use row_heights to ensure table fits bbox properly
            table = Table(reportlab_data, colWidths=col_widths, rowHeights=row_heights)
            logger.info(f"[TABLE] Created with {len(col_widths)} cols, {len(row_heights)} rows")

            # Apply table style
            # If cell_boxes rendered borders, skip GRID style (text-only rendering)
            style_commands = [
                ('FONT', (0, 0), (-1, -1), self.font_name if self.font_registered else 'Helvetica', font_size),
                ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
                ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
                ('LEFTPADDING', (0, 0), (-1, -1), 2),
                ('RIGHTPADDING', (0, 0), (-1, -1), 2),
                ('TOPPADDING', (0, 0), (-1, -1), 2),
                ('BOTTOMPADDING', (0, 0), (-1, -1), 2),
            ]

            # Only add GRID if cell_boxes didn't render borders
            if not cell_boxes_rendered:
                style_commands.insert(1, ('GRID', (0, 0), (-1, -1), 0.5, colors.black))
                logger.info("[TABLE] Adding GRID style (cell_boxes not used)")
            else:
                logger.info("[TABLE] Skipping GRID style (cell_boxes rendered borders)")

            style = TableStyle(style_commands)

            # Add header style if first row has headers
            if rows and rows[0]['cells'] and rows[0]['cells'][0].get('is_header'):
                style.add('BACKGROUND', (0, 0), (-1, 0), colors.lightgrey)
                style.add('FONT', (0, 0), (-1, 0), self.font_name if self.font_registered else 'Helvetica-Bold', font_size)

            # Add span commands for merged cells
            for span_cmd in span_commands:
                style.add(*span_cmd)

            table.setStyle(style)

            logger.info(f"[表格] 套用 {len(span_commands)} 個合併儲存格 (SPAN)")

            # Calculate actual table size after wrapping
            actual_width, actual_height = table.wrapOn(pdf_canvas, table_width, table_height)

            logger.info(f"[表格] 目標尺寸: {table_width:.0f}x{table_height:.0f}, 實際尺寸: {actual_width:.0f}x{actual_height:.0f}")

            # Scale table to fit bbox if it exceeds the target size
            scale_x = table_width / actual_width if actual_width > table_width else 1.0
            scale_y = table_height / actual_height if actual_height > table_height else 1.0
            scale_factor = min(scale_x, scale_y)  # Use smaller scale to fit both dimensions

            # Calculate the table top position in PDF coordinates
            # ReportLab uses bottom-left origin, so we need to position from TOP
            pdf_y_top = page_height - ocr_y_top  # Top of table in PDF coords

            # Calculate the actual bottom position based on scaled height
            # Table should be positioned so its TOP aligns with the bbox top
            scaled_height = actual_height * scale_factor
            pdf_y_bottom = pdf_y_top - scaled_height  # Bottom of scaled table

            logger.info(f"[表格] PDF座標: top={pdf_y_top:.0f}, bottom={pdf_y_bottom:.0f}, scaled_height={scaled_height:.0f}")

            if scale_factor < 1.0:
                logger.info(f"[表格] 縮放比例: {scale_factor:.2f} (需要縮小以適應 bbox)")
                # Apply scaling transformation
                pdf_canvas.saveState()
                pdf_canvas.translate(pdf_x, pdf_y_bottom)
                pdf_canvas.scale(scale_factor, scale_factor)
                # Draw at origin since we've already translated
                table.drawOn(pdf_canvas, 0, 0)
                pdf_canvas.restoreState()
            else:
                # Draw table at position without scaling
                # pdf_y should be the bottom of the table
                table.drawOn(pdf_canvas, pdf_x, pdf_y_bottom)

            logger.info(f"Drew table at ({pdf_x:.0f}, {pdf_y_bottom:.0f}) size {table_width:.0f}x{scaled_height:.0f} with {len(rows)} rows")

            # Draw embedded images (images detected inside the table region)
            embedded_images = table_element.get('embedded_images', [])
            if embedded_images and result_dir:
                logger.info(f"[TABLE] Drawing {len(embedded_images)} embedded images")
                for emb_img in embedded_images:
                    self._draw_embedded_image(
                        pdf_canvas, emb_img, page_height, result_dir, scale_w, scale_h
                    )

        except Exception as e:
            logger.warning(f"Failed to draw table region: {e}")
            import traceback
            traceback.print_exc()

    def _draw_embedded_image(
        self,
        pdf_canvas: canvas.Canvas,
        emb_img: Dict,
        page_height: float,
        result_dir: Path,
        scale_w: float = 1.0,
        scale_h: float = 1.0
    ):
        """Draw an embedded image inside a table region."""
        try:
            # Get image path
            saved_path = emb_img.get('saved_path', '')
            if not saved_path:
                return

            # Construct full path
            image_path = result_dir / saved_path
            if not image_path.exists():
                image_path = result_dir / Path(saved_path).name

            if not image_path.exists():
                logger.warning(f"Embedded image not found: {saved_path}")
                return

            # Get bbox from embedded image data
            bbox = emb_img.get('bbox', [])
            if not bbox or len(bbox) < 4:
                logger.warning(f"No bbox for embedded image: {saved_path}")
                return

            # Calculate position (bbox is [x0, y0, x1, y1])
            x0, y0, x1, y1 = bbox[0], bbox[1], bbox[2], bbox[3]

            # Apply scaling
            x0_scaled = x0 * scale_w
            y0_scaled = y0 * scale_h
            x1_scaled = x1 * scale_w
            y1_scaled = y1 * scale_h

            width = x1_scaled - x0_scaled
            height = y1_scaled - y0_scaled

            # Transform Y coordinate (ReportLab uses bottom-left origin)
            pdf_x = x0_scaled
            pdf_y = page_height - y1_scaled

            # Draw the image
            from reportlab.lib.utils import ImageReader
            img_reader = ImageReader(str(image_path))
            pdf_canvas.drawImage(
                img_reader, pdf_x, pdf_y, width, height,
                preserveAspectRatio=True, mask='auto'
            )

            logger.info(f"Drew embedded image at ({pdf_x:.0f}, {pdf_y:.0f}) size {width:.0f}x{height:.0f}")

        except Exception as e:
            logger.warning(f"Failed to draw embedded image: {e}")

    def _normalize_cell_boxes_to_grid(
        self,
        cell_boxes: List[List[float]],
        threshold: float = 10.0
    ) -> List[List[float]]:
        """
        Normalize cell boxes to create a proper aligned grid.

        Groups nearby coordinates and snaps them to a common value,
        eliminating the 2-11 pixel variations that cause skewed tables.

        Args:
            cell_boxes: List of cell bboxes [[x1,y1,x2,y2], ...]
            threshold: Maximum distance to consider coordinates as "same line"

        Returns:
            Normalized cell_boxes with aligned coordinates
        """
        if not cell_boxes or len(cell_boxes) < 2:
            return cell_boxes

        # Collect all X and Y coordinates
        x_coords = []  # (value, box_idx, is_x1)
        y_coords = []  # (value, box_idx, is_y1)

        for i, box in enumerate(cell_boxes):
            x1, y1, x2, y2 = box[0], box[1], box[2], box[3]
            x_coords.append((x1, i, True))   # x1 (left)
            x_coords.append((x2, i, False))  # x2 (right)
            y_coords.append((y1, i, True))   # y1 (top)
            y_coords.append((y2, i, False))  # y2 (bottom)

        def cluster_and_normalize(coords, threshold):
            """Cluster nearby coordinates and return mapping to normalized values."""
            if not coords:
                return {}

            # Sort by value
            sorted_coords = sorted(coords, key=lambda x: x[0])

            # Cluster nearby values
            clusters = []
            current_cluster = [sorted_coords[0]]

            for coord in sorted_coords[1:]:
                if coord[0] - current_cluster[-1][0] <= threshold:
                    current_cluster.append(coord)
                else:
                    clusters.append(current_cluster)
                    current_cluster = [coord]
            clusters.append(current_cluster)

            # Create mapping: (box_idx, is_first) -> normalized value
            mapping = {}
            for cluster in clusters:
                # Use average of cluster as normalized value
                avg_value = sum(c[0] for c in cluster) / len(cluster)
                for _, box_idx, is_first in cluster:
                    mapping[(box_idx, is_first)] = avg_value

            return mapping

        x_mapping = cluster_and_normalize(x_coords, threshold)
        y_mapping = cluster_and_normalize(y_coords, threshold)

        # Create normalized cell boxes
        normalized_boxes = []
        for i, box in enumerate(cell_boxes):
            x1_norm = x_mapping.get((i, True), box[0])
            x2_norm = x_mapping.get((i, False), box[2])
            y1_norm = y_mapping.get((i, True), box[1])
            y2_norm = y_mapping.get((i, False), box[3])
            normalized_boxes.append([x1_norm, y1_norm, x2_norm, y2_norm])

        logger.debug(f"[TABLE] Normalized {len(cell_boxes)} cell boxes to grid")
        return normalized_boxes

    def _draw_table_border_only(
        self,
        pdf_canvas: canvas.Canvas,
        table_element: Dict,
        page_height: float,
        scale_w: float = 1.0,
        scale_h: float = 1.0
    ):
        """
        Draw only the outer border of a table (for tables with bad cell_boxes quality).

        Text inside the table will be rendered using raw OCR positions.

        Args:
            pdf_canvas: ReportLab canvas object
            table_element: Table element dict
            page_height: Height of page in PDF coordinates
            scale_w: Scale factor for X coordinates
            scale_h: Scale factor for Y coordinates
        """
        table_bbox = table_element.get('bbox', [])
        if not table_bbox or len(table_bbox) < 4:
            return

        element_id = table_element.get('element_id', 'unknown')

        # Handle different bbox formats
        if isinstance(table_bbox, dict):
            x0, y0, x1, y1 = table_bbox['x0'], table_bbox['y0'], table_bbox['x1'], table_bbox['y1']
        elif isinstance(table_bbox[0], (int, float)):
            x0, y0, x1, y1 = table_bbox[0], table_bbox[1], table_bbox[2], table_bbox[3]
        else:
            return

        # Apply scaling
        pdf_x0 = x0 * scale_w
        pdf_y0 = y0 * scale_h
        pdf_x1 = x1 * scale_w
        pdf_y1 = y1 * scale_h

        # Convert to PDF coordinates (flip Y)
        pdf_top = page_height - pdf_y0
        pdf_bottom = page_height - pdf_y1
        width = pdf_x1 - pdf_x0
        height = pdf_y1 - pdf_y0

        # Draw outer border only
        pdf_canvas.setStrokeColor(colors.black)
        pdf_canvas.setLineWidth(0.5)
        pdf_canvas.rect(pdf_x0, pdf_bottom, width, height, stroke=1, fill=0)

        logger.info(f"[TABLE] {element_id}: Drew border only (bad cell_boxes quality)")

    def _check_cell_boxes_quality(self, cell_boxes: List, element_id: str = "") -> str:
        """
        Check the quality of cell_boxes to determine rendering strategy.

        Always returns 'good' to use pure PP-Structure output (quality check removed).

        Args:
            cell_boxes: List of cell bounding boxes
            element_id: Optional element ID for logging

        Returns:
            'good' - always use cell_boxes rendering
        """
        logger.debug(f"[TABLE QUALITY] {element_id}: good - pure PP-Structure mode")
        return 'good'

    def _draw_table_with_cell_boxes(
        self,
        pdf_canvas: canvas.Canvas,
        table_element: Dict,
        page_height: float,
        scale_w: float = 1.0,
        scale_h: float = 1.0,
        result_dir: Optional[Path] = None
    ):
        """
        Draw table borders using cell_boxes for accurate positioning.

        LAYERED RENDERING APPROACH:
        - This method ONLY draws cell borders and embedded images
        - Text is rendered separately using raw OCR positions (via GapFillingService)
        - This decouples visual structure (borders) from content (text)

        FALLBACK: If cell_boxes are incomplete, always draws the outer table
        border using the table's bbox to ensure table boundaries are visible.

        Args:
            pdf_canvas: ReportLab canvas object
            table_element: Table element dict with cell_boxes
            page_height: Height of page in PDF coordinates
            scale_w: Scale factor for X coordinates
            scale_h: Scale factor for Y coordinates
            result_dir: Directory containing result files (for embedded images)
        """
        try:
            cell_boxes = table_element.get('cell_boxes', [])
            table_bbox = table_element.get('bbox', [])

            # Check cell_boxes quality - skip if they don't form a proper grid
            if cell_boxes and len(cell_boxes) > 2:
                # Count overlapping cell pairs
                overlap_count = 0
                for i, box1 in enumerate(cell_boxes):
                    for j, box2 in enumerate(cell_boxes):
                        if i >= j:
                            continue
                        x_overlap = box1[0] < box2[2] and box1[2] > box2[0]
                        y_overlap = box1[1] < box2[3] and box1[3] > box2[1]
                        if x_overlap and y_overlap:
                            overlap_count += 1

                # If more than 25% of cell pairs overlap, cell_boxes are unreliable
                # Increased from 10% to 25% to allow more tables to use cell_boxes rendering
                # which provides better visual fidelity than ReportLab Table fallback
                total_pairs = len(cell_boxes) * (len(cell_boxes) - 1) // 2
                overlap_ratio = overlap_count / total_pairs if total_pairs > 0 else 0

                if overlap_ratio > 0.25:
                    logger.warning(
                        f"[TABLE] Skipping cell_boxes rendering: {overlap_count}/{total_pairs} "
                        f"({overlap_ratio:.1%}) cell pairs overlap - using ReportLab Table fallback"
                    )
                    return False  # Return False to trigger ReportLab Table fallback

            if not cell_boxes:
                # Fallback: draw outer border only when no cell_boxes
                if table_bbox and len(table_bbox) >= 4:
                    # Handle different bbox formats (list or dict)
                    if isinstance(table_bbox, dict):
                        tx1 = float(table_bbox.get('x0', 0))
                        ty1 = float(table_bbox.get('y0', 0))
                        tx2 = float(table_bbox.get('x1', 0))
                        ty2 = float(table_bbox.get('y1', 0))
                    else:
                        tx1, ty1, tx2, ty2 = table_bbox[:4]

                    # Apply scaling
                    tx1_scaled = tx1 * scale_w
                    ty1_scaled = ty1 * scale_h
                    tx2_scaled = tx2 * scale_w
                    ty2_scaled = ty2 * scale_h

                    table_width = tx2_scaled - tx1_scaled
                    table_height = ty2_scaled - ty1_scaled

                    # Transform Y coordinate (PDF uses bottom-left origin)
                    pdf_x = tx1_scaled
                    pdf_y = page_height - ty2_scaled  # Bottom of table in PDF coords

                    # Draw outer table border (slightly thicker for visibility)
                    pdf_canvas.setStrokeColor(colors.black)
                    pdf_canvas.setLineWidth(1.0)
                    pdf_canvas.rect(pdf_x, pdf_y, table_width, table_height, stroke=1, fill=0)
                    logger.info(f"[TABLE] Drew outer table border at [{int(tx1)},{int(ty1)},{int(tx2)},{int(ty2)}]")
                logger.warning("[TABLE] No cell_boxes available, only outer border drawn")
                # Still draw embedded images even without cell borders
                embedded_images = table_element.get('embedded_images', [])
                if embedded_images and result_dir:
                    for emb_img in embedded_images:
                        self._draw_embedded_image(
                            pdf_canvas, emb_img, page_height, result_dir, scale_w, scale_h
                        )
                return True  # Outer border drawn successfully

            # Normalize cell boxes to create aligned grid
            cell_boxes = self._normalize_cell_boxes_to_grid(cell_boxes)

            logger.info(f"[TABLE] Drawing {len(cell_boxes)} cells using grid lines (avoiding duplicates)")

            # Collect unique grid lines to avoid drawing duplicate/overlapping lines
            h_lines = set()  # Horizontal lines: (y, x_start, x_end)
            v_lines = set()  # Vertical lines: (x, y_start, y_end)

            for box in cell_boxes:
                x1, y1, x2, y2 = box[0], box[1], box[2], box[3]

                # Apply scaling
                x1_s = x1 * scale_w
                y1_s = y1 * scale_h
                x2_s = x2 * scale_w
                y2_s = y2 * scale_h

                # Round to 1 decimal place to help with deduplication
                x1_s, y1_s, x2_s, y2_s = round(x1_s, 1), round(y1_s, 1), round(x2_s, 1), round(y2_s, 1)

                # Add horizontal lines (top and bottom of cell)
                h_lines.add((y1_s, x1_s, x2_s))  # Top line
                h_lines.add((y2_s, x1_s, x2_s))  # Bottom line

                # Add vertical lines (left and right of cell)
                v_lines.add((x1_s, y1_s, y2_s))  # Left line
                v_lines.add((x2_s, y1_s, y2_s))  # Right line

            # Draw unique horizontal lines
            pdf_canvas.setStrokeColor(colors.black)
            pdf_canvas.setLineWidth(0.5)

            for y, x_start, x_end in h_lines:
                pdf_y = page_height - y  # Transform Y coordinate
                pdf_canvas.line(x_start, pdf_y, x_end, pdf_y)

            # Draw unique vertical lines
            for x, y_start, y_end in v_lines:
                pdf_y_start = page_height - y_start
                pdf_y_end = page_height - y_end
                pdf_canvas.line(x, pdf_y_start, x, pdf_y_end)

            logger.info(f"[TABLE] Drew {len(h_lines)} horizontal + {len(v_lines)} vertical grid lines")

            # Draw embedded images
            embedded_images = table_element.get('embedded_images', [])
            if embedded_images and result_dir:
                logger.info(f"[TABLE] Drawing {len(embedded_images)} embedded images")
                for emb_img in embedded_images:
                    self._draw_embedded_image(
                        pdf_canvas, emb_img, page_height, result_dir, scale_w, scale_h
                    )

            return True

        except Exception as e:
            logger.warning(f"[TABLE] Failed to draw cell borders: {e}")
            import traceback
            traceback.print_exc()
            return False

    def draw_image_region(
        self,
        pdf_canvas: canvas.Canvas,
        region: Dict,
        page_height: float,
        result_dir: Path,
        scale_w: float = 1.0,
        scale_h: float = 1.0
    ):
        """
        Draw an image region by embedding the extracted image

        Handles images extracted by PP-StructureV3 (tables, figures, charts, etc.)

        Args:
            pdf_canvas: ReportLab canvas object
            region: Image metadata dict with image_path and bbox
            page_height: Height of page (for coordinate transformation)
            result_dir: Directory containing result files
            scale_w: Scale factor for X coordinates (PDF width / OCR width)
            scale_h: Scale factor for Y coordinates (PDF height / OCR height)
        """
        try:
            image_path_str = region.get('image_path', '')
            if not image_path_str:
                return

            # Construct full path to image
            # saved_path is relative to result_dir (e.g., "imgs/element_id.png")
            image_path = result_dir / image_path_str

            # Fallback for legacy data
            if not image_path.exists():
                image_path = result_dir / Path(image_path_str).name

            if not image_path.exists():
                logger.warning(f"Image not found: {image_path_str} (in {result_dir})")
                return

            # Get bbox for positioning
            bbox = region.get('bbox', [])
            if not bbox:
                logger.warning(f"No bbox for image {image_path_str}")
                return

            # Handle different bbox formats
            if isinstance(bbox, dict):
                # Dict format from UnifiedDocument: {"x0": ..., "y0": ..., "x1": ..., "y1": ...}
                if 'x0' in bbox and 'y0' in bbox and 'x1' in bbox and 'y1' in bbox:
                    ocr_x_left_raw = float(bbox['x0'])
                    ocr_y_top_raw = float(bbox['y0'])
                    ocr_x_right_raw = float(bbox['x1'])
                    ocr_y_bottom_raw = float(bbox['y1'])
                else:
                    logger.warning(f"Dict bbox missing required keys for image: {bbox}")
                    return
            elif isinstance(bbox, list):
                if len(bbox) < 4:
                    logger.warning(f"List bbox too short for image: {bbox}")
                    return
                # Polygon format [[x,y], [x,y], [x,y], [x,y]]
                if isinstance(bbox[0], list):
                    ocr_x_left_raw = bbox[0][0]
                    ocr_y_top_raw = bbox[0][1]
                    ocr_x_right_raw = bbox[2][0]
                    ocr_y_bottom_raw = bbox[2][1]
                # Simple list format [x0, y0, x1, y1]
                elif isinstance(bbox[0], (int, float)):
                    ocr_x_left_raw = bbox[0]
                    ocr_y_top_raw = bbox[1]
                    ocr_x_right_raw = bbox[2]
                    ocr_y_bottom_raw = bbox[3]
                else:
                    logger.warning(f"Unexpected bbox list format for image: {bbox}")
                    return
            else:
                logger.warning(f"Invalid bbox format for image: {bbox}")
                return

            logger.info(f"[圖片] '{image_path_str}' OCR原始座標: L={ocr_x_left_raw:.0f}, T={ocr_y_top_raw:.0f}, R={ocr_x_right_raw:.0f}, B={ocr_y_bottom_raw:.0f}")

            # Apply scaling
            ocr_x_left = ocr_x_left_raw * scale_w
            ocr_y_top = ocr_y_top_raw * scale_h
            ocr_x_right = ocr_x_right_raw * scale_w
            ocr_y_bottom = ocr_y_bottom_raw * scale_h

            # Calculate bbox dimensions (after scaling)
            bbox_width = abs(ocr_x_right - ocr_x_left)
            bbox_height = abs(ocr_y_bottom - ocr_y_top)

            # Transform coordinates: OCR (top-left origin) → PDF (bottom-left origin)
            # CRITICAL: Y-axis flip!
            # For images, we position at bottom-left corner
            pdf_x_left = ocr_x_left
            pdf_y_bottom = page_height - ocr_y_bottom  # Flip Y-axis

            logger.info(f"[圖片] '{image_path_str}' → PDF位置: ({pdf_x_left:.1f}, {pdf_y_bottom:.1f}), 寬x高: {bbox_width:.0f}x{bbox_height:.0f}")

            # Draw image using ReportLab
            # drawImage expects: (path, x, y, width, height)
            # where (x, y) is the bottom-left corner of the image
            pdf_canvas.drawImage(
                str(image_path),
                pdf_x_left,
                pdf_y_bottom,
                width=bbox_width,
                height=bbox_height,
                preserveAspectRatio=True,
                mask='auto'  # Handle transparency
            )

            logger.info(f"[圖片] ✓ 成功繪製 '{image_path_str}'")

        except Exception as e:
            logger.warning(f"Failed to draw image region: {e}")

    def generate_layout_pdf(
        self,
        json_path: Path,
        output_path: Path,
        source_file_path: Optional[Path] = None
    ) -> bool:
        """
        Generate layout-preserving PDF from OCR JSON data

        Args:
            json_path: Path to OCR JSON file
            output_path: Path to save generated PDF
            source_file_path: Optional path to original source file for dimension extraction

        Returns:
            True if successful, False otherwise
        """
        try:
            # Load JSON data
            ocr_data = self.load_ocr_json(json_path)
            if not ocr_data:
                return False

            # Check if this is new UnifiedDocument format (has 'pages' with elements)
            # vs old OCR format (has 'text_regions')
            if 'pages' in ocr_data and isinstance(ocr_data.get('pages'), list):
                # New UnifiedDocument format - convert and use Direct track rendering
                logger.info("Detected UnifiedDocument JSON format, using Direct track rendering")
                unified_doc = self._json_to_unified_document(ocr_data, json_path.parent)
                if unified_doc:
                    return self.generate_from_unified_document(
                        unified_doc=unified_doc,
                        output_path=output_path,
                        source_file_path=source_file_path
                    )
                else:
                    logger.error("Failed to convert JSON to UnifiedDocument")
                    return False
            else:
                # Old OCR format - use legacy generation
                logger.info("Detected legacy OCR JSON format, using OCR track rendering")
                return self._generate_pdf_from_data(
                    ocr_data=ocr_data,
                    output_path=output_path,
                    source_file_path=source_file_path,
                    json_parent_dir=json_path.parent
                )

        except Exception as e:
            logger.error(f"Failed to generate PDF: {e}")
            import traceback
            traceback.print_exc()
            return False

    def _json_to_unified_document(self, json_data: Dict, result_dir: Path) -> Optional['UnifiedDocument']:
        """
        Convert JSON dict to UnifiedDocument object.

        Args:
            json_data: Loaded JSON dictionary in UnifiedDocument format
            result_dir: Directory containing image files

        Returns:
            UnifiedDocument object or None if conversion fails
        """
        try:
            from datetime import datetime

            # Parse metadata
            metadata_dict = json_data.get('metadata', {})

            # Parse processing track
            track_str = metadata_dict.get('processing_track', 'direct')
            try:
                processing_track = ProcessingTrack(track_str)
            except ValueError:
                processing_track = ProcessingTrack.DIRECT

            # Create DocumentMetadata
            metadata = DocumentMetadata(
                filename=metadata_dict.get('filename', ''),
                file_type=metadata_dict.get('file_type', 'pdf'),
                file_size=metadata_dict.get('file_size', 0),
                created_at=datetime.fromisoformat(metadata_dict.get('created_at', datetime.now().isoformat()).replace('Z', '+00:00')),
                processing_track=processing_track,
                processing_time=metadata_dict.get('processing_time', 0),
                language=metadata_dict.get('language'),
                title=metadata_dict.get('title'),
                author=metadata_dict.get('author'),
                subject=metadata_dict.get('subject'),
                keywords=metadata_dict.get('keywords'),
                producer=metadata_dict.get('producer'),
                creator=metadata_dict.get('creator'),
                creation_date=datetime.fromisoformat(metadata_dict['creation_date'].replace('Z', '+00:00')) if metadata_dict.get('creation_date') else None,
                modification_date=datetime.fromisoformat(metadata_dict['modification_date'].replace('Z', '+00:00')) if metadata_dict.get('modification_date') else None,
            )

            # Parse pages
            pages = []
            for page_dict in json_data.get('pages', []):
                # Parse page dimensions
                dims = page_dict.get('dimensions', {})
                if not dims:
                    # Fallback dimensions
                    dims = {'width': 595.32, 'height': 841.92}
                dimensions = Dimensions(
                    width=dims.get('width', 595.32),
                    height=dims.get('height', 841.92),
                    dpi=dims.get('dpi')
                )

                # Parse elements
                elements = []
                for elem_dict in page_dict.get('elements', []):
                    element = self._json_to_document_element(elem_dict)
                    if element:
                        elements.append(element)

                page = Page(
                    page_number=page_dict.get('page_number', 1),
                    dimensions=dimensions,
                    elements=elements,
                    metadata=page_dict.get('metadata', {})
                )
                pages.append(page)

            # Create UnifiedDocument
            unified_doc = UnifiedDocument(
                document_id=json_data.get('document_id', ''),
                metadata=metadata,
                pages=pages,
                processing_errors=json_data.get('processing_errors', [])
            )

            logger.info(f"Converted JSON to UnifiedDocument: {len(pages)} pages, track={processing_track.value}")
            return unified_doc

        except Exception as e:
            logger.error(f"Failed to convert JSON to UnifiedDocument: {e}")
            import traceback
            traceback.print_exc()
            return None

    def _json_to_document_element(self, elem_dict: Dict) -> Optional['DocumentElement']:
        """
        Convert JSON dict to DocumentElement.

        Args:
            elem_dict: Element dictionary from JSON

        Returns:
            DocumentElement or None if conversion fails
        """
        try:
            # Parse element type
            type_str = elem_dict.get('type', 'text')
            try:
                elem_type = ElementType(type_str)
            except ValueError:
                # Fallback to TEXT for unknown types
                elem_type = ElementType.TEXT
                logger.warning(f"Unknown element type '{type_str}', falling back to TEXT")

            # Content-based HTML table detection: reclassify text elements with HTML table content
            content = elem_dict.get('content', '')
            if elem_type == ElementType.TEXT and isinstance(content, str) and '<table' in content.lower():
                logger.info(f"Reclassifying element from TEXT to TABLE due to HTML table content")
                elem_type = ElementType.TABLE

            # Parse bounding box
            bbox_dict = elem_dict.get('bbox', {})
            bbox = BoundingBox(
                x0=bbox_dict.get('x0', 0),
                y0=bbox_dict.get('y0', 0),
                x1=bbox_dict.get('x1', 0),
                y1=bbox_dict.get('y1', 0)
            )

            # Parse style if present
            style = None
            if 'style' in elem_dict and elem_dict['style']:
                style_dict = elem_dict['style']
                style = StyleInfo(
                    font_name=style_dict.get('font_name'),
                    font_size=style_dict.get('font_size'),
                    font_weight=style_dict.get('font_weight'),
                    font_style=style_dict.get('font_style'),
                    text_color=style_dict.get('text_color'),
                    bg_color=style_dict.get('bg_color') or style_dict.get('background_color'),
                    alignment=style_dict.get('alignment'),
                )

            # Parse children (spans)
            children = []
            for child_dict in elem_dict.get('children', []):
                child = self._json_to_document_element(child_dict)
                if child:
                    children.append(child)

            # Process content based on element type
            content = elem_dict.get('content', '')

            # For TABLE elements, convert dict content to TableData object
            if elem_type == ElementType.TABLE and isinstance(content, dict) and 'cells' in content:
                try:
                    content = TableData.from_dict(content)
                    logger.debug(f"Converted table dict to TableData: {content.rows}x{content.cols}, {len(content.cells)} cells")
                except Exception as e:
                    logger.warning(f"Failed to convert table dict to TableData: {e}")
                    # Keep original dict as fallback

            # Create element
            element = DocumentElement(
                element_id=elem_dict.get('element_id', ''),
                type=elem_type,
                content=content,
                bbox=bbox,
                confidence=elem_dict.get('confidence'),
                style=style,
                metadata=elem_dict.get('metadata', {}),
                children=children
            )

            return element

        except Exception as e:
            logger.warning(f"Failed to convert element: {e}")
            return None

    def _is_list_item_fallback(self, element: 'DocumentElement') -> bool:
        """
        Fallback detection for list items not marked with ElementType.LIST_ITEM.

        Checks metadata and text patterns to identify list items.

        Args:
            element: Document element to check

        Returns:
            True if element appears to be a list item
        """
        # Skip if already categorized as table or image
        if element.type in [ElementType.TABLE, ElementType.IMAGE, ElementType.FIGURE,
                           ElementType.CHART, ElementType.DIAGRAM]:
            return False

        # Check metadata for list-related fields
        if element.metadata:
            # Check for list_level metadata
            if 'list_level' in element.metadata:
                return True
            # Check for parent_item (indicates list hierarchy)
            if 'parent_item' in element.metadata:
                return True
            # Check for children (could be parent list item)
            if 'children' in element.metadata and element.metadata['children']:
                return True

        # Check text content for list patterns
        if element.is_text:
            text = element.get_text().lstrip()
            # Ordered list pattern: starts with number followed by . or )
            if re.match(r'^\d+[\.\)]\s', text):
                return True
            # Unordered list pattern: starts with bullet character
            if re.match(r'^[•·▪▫◦‣⁃\-\*]\s', text):
                return True

        return False

    def _draw_list_elements_direct(
        self,
        pdf_canvas: canvas.Canvas,
        list_elements: List['DocumentElement'],
        page_height: float
    ):
        """
        Draw list elements with proper sequential numbering and formatting.

        This method processes all list items on a page, groups them into lists,
        and assigns proper sequential numbering to ordered lists.

        Args:
            pdf_canvas: ReportLab canvas object
            list_elements: List of LIST_ITEM elements
            page_height: Page height for coordinate transformation
        """
        if not list_elements:
            return

        # Sort list items by position (top to bottom, left to right)
        sorted_items = sorted(list_elements, key=lambda e: (e.bbox.y0, e.bbox.x0))

        # Group list items into lists based on proximity and level
        list_groups = []
        current_group = []
        prev_y = None
        prev_level = None
        max_gap = 30  # Maximum vertical gap between items in same list (in points)

        for item in sorted_items:
            level = item.metadata.get('list_level', 0) if item.metadata else 0
            y_pos = item.bbox.y0

            # Check if this item belongs to current group
            if current_group and prev_y is not None:
                gap = abs(y_pos - prev_y)
                # Start new group if gap is too large or level changed significantly
                if gap > max_gap or (prev_level is not None and level != prev_level):
                    list_groups.append(current_group)
                    current_group = []

            current_group.append(item)
            prev_y = y_pos
            prev_level = level

        if current_group:
            list_groups.append(current_group)

        # Process each list group
        for group in list_groups:
            # Detect list type from first item
            first_item = group[0]
            text_content = first_item.get_text()
            text_stripped = text_content.lstrip()

            list_type = None
            list_counter = 1

            # Determine list type
            if re.match(r'^\d+[\.\)]\s', text_stripped):
                list_type = 'ordered'
                # Extract starting number
                match = re.match(r'^(\d+)[\.\)]\s', text_stripped)
                if match:
                    list_counter = int(match.group(1))
            elif re.match(r'^[•·▪▫◦‣⁃]\s', text_stripped):
                list_type = 'unordered'

            # Draw each item in the group with proper spacing
            # Track cumulative Y offset to apply spacing_after between items
            cumulative_y_offset = 0

            for item_idx, item in enumerate(group):
                # Prepare list marker based on type
                if list_type == 'ordered':
                    list_marker = f"{list_counter}. "
                    list_counter += 1
                elif list_type == 'unordered':
                    list_marker = "• "
                else:
                    list_marker = ""  # No marker if type unknown

                # Store list marker in item metadata for _draw_text_element_direct
                if not item.metadata:
                    item.metadata = {}
                item.metadata['_list_marker'] = list_marker
                item.metadata['_list_type'] = list_type

                # Add default list item spacing if not specified
                # This ensures consistent spacing between list items
                desired_spacing_after = item.metadata.get('spacing_after', 0)
                if desired_spacing_after == 0:
                    # Default list item spacing: 3 points between items (except last item)
                    if item_idx < len(group) - 1:
                        desired_spacing_after = 3.0
                        item.metadata['spacing_after'] = desired_spacing_after

                # Draw the list item with cumulative Y offset
                self._draw_text_element_direct(pdf_canvas, item, page_height, y_offset=cumulative_y_offset)

                # Calculate spacing to add after this item
                if item_idx < len(group) - 1 and desired_spacing_after > 0:
                    next_item = group[item_idx + 1]

                    # Calculate actual vertical gap between items (in document coordinates)
                    # Note: Y increases downward in document coordinates
                    actual_gap = next_item.bbox.y0 - item.bbox.y1

                    # If actual gap is less than desired spacing, add offset to push next item down
                    if actual_gap < desired_spacing_after:
                        additional_spacing = desired_spacing_after - actual_gap
                        cumulative_y_offset -= additional_spacing  # Negative because PDF Y increases upward
                        logger.debug(f"Adding {additional_spacing:.1f}pt spacing after list item {item.element_id} "
                                   f"(actual_gap={actual_gap:.1f}pt, desired={desired_spacing_after:.1f}pt)")

    def _draw_text_with_spans(
        self,
        pdf_canvas: canvas.Canvas,
        spans: List['DocumentElement'],
        line_x: float,
        line_y: float,
        default_font_size: float,
        max_width: float = None
    ) -> float:
        """
        Draw text with inline span styling (mixed styles within a line).

        Args:
            pdf_canvas: ReportLab canvas object
            spans: List of span DocumentElements
            line_x: Starting X position
            line_y: Y position
            default_font_size: Default font size if span has none
            max_width: Maximum width available (for scaling if needed)

        Returns:
            Total width of drawn text
        """
        if not spans:
            return 0

        # First pass: calculate total width with original sizes
        total_width = 0
        span_data = []  # Store (span, text, font, size) for rendering

        for span in spans:
            span_text = span.get_text()
            if not span_text:
                continue

            # Apply span-specific styling to get font and size
            if span.style:
                self._apply_text_style(pdf_canvas, span.style, default_size=default_font_size)
            else:
                font_name = self.font_name if self.font_registered else 'Helvetica'
                pdf_canvas.setFont(font_name, default_font_size)

            current_font = pdf_canvas._fontname
            current_size = pdf_canvas._fontsize

            # Calculate span width
            span_width = pdf_canvas.stringWidth(span_text, current_font, current_size)
            total_width += span_width

            span_data.append((span, span_text, current_font, current_size, span_width))

        # Calculate scale factor if needed
        scale_factor = 1.0
        if max_width and total_width > max_width:
            scale_factor = (max_width / total_width) * 0.95  # 95% to leave margin
            logger.debug(f"Scaling spans: total_width={total_width:.1f}pt > max_width={max_width:.1f}pt, scale={scale_factor:.2f}")

        # Second pass: draw spans with scaling
        x_pos = line_x

        for span, span_text, font_name, original_size, span_width in span_data:
            # Apply scaled font size
            scaled_size = original_size * scale_factor
            scaled_size = max(scaled_size, 3)  # Minimum 3pt

            # Set font with scaled size
            pdf_canvas.setFont(font_name, scaled_size)

            # Draw this span
            pdf_canvas.drawString(x_pos, line_y, span_text)

            # Calculate actual width with scaled size and advance position
            actual_width = pdf_canvas.stringWidth(span_text, font_name, scaled_size)
            x_pos += actual_width

        return total_width * scale_factor

    def _draw_text_element_direct(
        self,
        pdf_canvas: canvas.Canvas,
        element: 'DocumentElement',
        page_height: float,
        y_offset: float = 0
    ):
        """
        Draw text element with Direct track rich formatting.
        FIXED: Correctly handles multi-line blocks and spans coordinates.

        Prioritizes span-based rendering (using precise bbox from each span),
        falls back to block-level rendering with corrected Y-axis logic.

        Args:
            pdf_canvas: ReportLab canvas object
            element: DocumentElement with text content
            page_height: Page height for coordinate transformation
            y_offset: Optional Y coordinate offset (for list spacing), in PDF coordinates
        """
        try:
            text_content = element.get_text()
            if not text_content:
                return

            # Get bounding box
            bbox = element.bbox
            if not bbox:
                logger.warning(f"No bbox for text element {element.element_id}")
                return

            bbox_width = bbox.x1 - bbox.x0
            bbox_height = bbox.y1 - bbox.y0

            # --- FIX 1: Prioritize Span-based Drawing (Precise Layout) ---
            # DirectExtractionEngine provides children (spans) with precise bboxes.
            # Using these preserves exact layout, kerning, and multi-column positioning.
            if element.children and len(element.children) > 0:
                for span in element.children:
                    span_text = span.get_text()
                    if not span_text:
                        continue

                    # Use span's own bbox for positioning
                    s_bbox = span.bbox
                    if not s_bbox:
                        continue

                    # Calculate font size from span style or bbox
                    s_font_size = 10  # default
                    if span.style and span.style.font_size:
                        s_font_size = span.style.font_size
                    else:
                        # Estimate from bbox height
                        s_font_size = (s_bbox.y1 - s_bbox.y0) * 0.75
                        s_font_size = max(min(s_font_size, 72), 4)

                    # Apply span style
                    if span.style:
                        self._apply_text_style(pdf_canvas, span.style, default_size=s_font_size)
                    else:
                        font_name = self.font_name if self.font_registered else 'Helvetica'
                        pdf_canvas.setFont(font_name, s_font_size)

                    # Transform coordinates
                    # PyMuPDF y1 is bottom of text box. ReportLab draws at baseline.
                    # Using y1 with a small offset (20% of font size) approximates baseline position.
                    span_pdf_x = s_bbox.x0
                    span_pdf_y = page_height - s_bbox.y1 + (s_font_size * 0.2)

                    pdf_canvas.drawString(span_pdf_x, span_pdf_y + y_offset, span_text)

                # If we drew spans, we are done. Do not draw the block text on top.
                logger.debug(f"Drew {len(element.children)} spans using precise bbox positioning")
                return

            # --- FIX 2: Block-level Fallback (Corrected Y-Axis Logic) ---
            # Used when no spans are available (e.g. filtered text or modified structures)

            # Calculate font size from bbox height
            font_size = bbox_height * 0.75
            font_size = max(min(font_size, 72), 4)  # Clamp 4-72pt

            # Apply style if available
            alignment = 'left'  # Default alignment
            if hasattr(element, 'style') and element.style:
                self._apply_text_style(pdf_canvas, element.style, default_size=font_size)
                # Get alignment from style
                if hasattr(element.style, 'alignment') and element.style.alignment:
                    alignment = element.style.alignment
            else:
                # Use default font
                font_name = self.font_name if self.font_registered else 'Helvetica'
                pdf_canvas.setFont(font_name, font_size)

            # Detect list items and extract list properties
            is_list_item = (element.type == ElementType.LIST_ITEM)
            list_level = element.metadata.get('list_level', 0) if element.metadata else 0

            # Get pre-computed list marker from metadata (set by _draw_list_elements_direct)
            list_marker = element.metadata.get('_list_marker', '') if element.metadata else ''
            list_type = element.metadata.get('_list_type') if element.metadata else None

            # If no pre-computed marker, remove original marker from text
            if is_list_item and list_marker:
                # Remove original marker from text content
                text_stripped = text_content.lstrip()
                # Remove ordered list marker
                text_content = re.sub(r'^\d+[\.\)]\s', '', text_stripped)
                # Remove unordered list marker
                text_content = re.sub(r'^[•·▪▫◦‣⁃]\s', '', text_content)

            # Get indentation from metadata (in points)
            indent = element.metadata.get('indent', 0) if element.metadata else 0
            first_line_indent = element.metadata.get('first_line_indent', indent) if element.metadata else indent

            # Apply list indentation (20pt per level)
            if is_list_item:
                list_indent = list_level * 20  # 20pt per level
                indent += list_indent
                first_line_indent += list_indent

            # Get paragraph spacing
            paragraph_spacing_before = element.metadata.get('spacing_before', 0) if element.metadata else 0
            paragraph_spacing_after = element.metadata.get('spacing_after', 0) if element.metadata else 0

            # --- CRITICAL FIX: Start from TOP of block (y0), not bottom (y1) ---
            pdf_x = bbox.x0
            pdf_y_top = page_height - bbox.y0 - paragraph_spacing_before + y_offset

            # Handle line breaks
            lines = text_content.split('\n')
            line_height = font_size * 1.2  # 120% of font size

            # Calculate list marker width for multi-line alignment
            marker_width = 0
            if is_list_item and list_marker:
                # Use current font to calculate marker width
                marker_width = pdf_canvas.stringWidth(list_marker, pdf_canvas._fontname, font_size)

            # Draw each line with alignment
            for i, line in enumerate(lines):
                if not line.strip():
                    # Empty line: skip
                    continue

                # Calculate Y position: Start from top, move down by line_height for each line
                # The first line's baseline is approx 1 line_height below the top
                line_y = pdf_y_top - ((i + 1) * line_height) + (font_size * 0.25)  # 0.25 adjust for baseline

                # Get current font info
                font_name = pdf_canvas._fontname
                current_font_size = pdf_canvas._fontsize

                # Calculate line indentation
                line_indent = first_line_indent if i == 0 else indent

                # For list items: align subsequent lines with text after marker
                if is_list_item and i > 0 and marker_width > 0:
                    line_indent += marker_width

                # Prepend list marker to first line
                rendered_line = line
                if is_list_item and i == 0 and list_marker:
                    rendered_line = list_marker + line

                # Calculate text width
                text_width = pdf_canvas.stringWidth(rendered_line, font_name, current_font_size)
                available_width = bbox_width - line_indent

                # Scale font if needed
                if text_width > available_width and available_width > 0:
                    scale_factor = available_width / text_width
                    scaled_size = current_font_size * scale_factor * 0.95
                    scaled_size = max(scaled_size, 3)
                    pdf_canvas.setFont(font_name, scaled_size)
                    text_width = pdf_canvas.stringWidth(rendered_line, font_name, scaled_size)
                    current_font_size = scaled_size

                # Calculate X position based on alignment
                line_x = pdf_x + line_indent

                if alignment == 'center':
                    line_x = pdf_x + (bbox_width - text_width) / 2
                elif alignment == 'right':
                    line_x = pdf_x + bbox_width - text_width
                elif alignment == 'justify' and i < len(lines) - 1:
                    # Justify: distribute extra space between words (except last line)
                    words = rendered_line.split()
                    if len(words) > 1:
                        total_word_width = sum(pdf_canvas.stringWidth(word, font_name, current_font_size) for word in words)
                        extra_space = available_width - total_word_width
                        if extra_space > 0:
                            word_spacing = extra_space / (len(words) - 1)

                            # Draw words with calculated spacing
                            x_pos = pdf_x + line_indent
                            for word in words:
                                pdf_canvas.drawString(x_pos, line_y, word)
                                word_width = pdf_canvas.stringWidth(word, font_name, current_font_size)
                                x_pos += word_width + word_spacing

                            # Reset font for next line and skip normal drawString
                            if text_width > available_width:
                                pdf_canvas.setFont(font_name, font_size)
                            continue

                # Draw the line at calculated position
                pdf_canvas.drawString(line_x, line_y, rendered_line)

                # Reset font size for next line
                if text_width > available_width:
                    pdf_canvas.setFont(font_name, font_size)

            # Calculate actual text height used
            actual_text_height = len(lines) * line_height
            bbox_bottom_margin = bbox_height - actual_text_height - paragraph_spacing_before

            # Note: For list items, spacing_after is applied via y_offset in _draw_list_elements_direct
            # For other elements, spacing is inherent in element positioning (bbox-based layout)
            list_info = f", list={list_type}, level={list_level}" if is_list_item else ""
            y_offset_info = f", y_offset={y_offset:.1f}pt" if y_offset != 0 else ""
            logger.debug(f"Drew text element (fallback): {text_content[:30]}... "
                        f"({len(lines)} lines, align={alignment}, indent={indent}{list_info}{y_offset_info}, "
                        f"spacing_before={paragraph_spacing_before}, spacing_after={paragraph_spacing_after}, "
                        f"actual_height={actual_text_height:.1f}, bbox_bottom_margin={bbox_bottom_margin:.1f})")

        except Exception as e:
            logger.error(f"Failed to draw text element {element.element_id}: {e}")

    def _build_rows_from_cells_dict(self, content: dict) -> list:
        """
        Build row structure from cells dict (from Direct extraction JSON).

        The cells structure from Direct extraction:
        {
            "rows": 6,
            "cols": 2,
            "cells": [
                {"row": 0, "col": 0, "content": "...", "row_span": 1, "col_span": 2},
                {"row": 0, "col": 1, "content": "..."},
                ...
            ]
        }

        Returns format compatible with HTMLTableParser output (with colspan/rowspan/col):
        [
            {"cells": [{"text": "...", "colspan": 1, "rowspan": 1, "col": 0}, ...]},
            {"cells": [{"text": "...", "colspan": 1, "rowspan": 1, "col": 0}, ...]},
            ...
        ]

        Note: This returns actual cells per row with their absolute column positions.
        The table renderer uses 'col' to place cells correctly in the grid.
        """
        try:
            num_rows = content.get('rows', 0)
            num_cols = content.get('cols', 0)
            cells = content.get('cells', [])

            if not cells or num_rows == 0 or num_cols == 0:
                return []

            # Group cells by row
            cells_by_row = {}
            for cell in cells:
                row_idx = cell.get('row', 0)
                if row_idx not in cells_by_row:
                    cells_by_row[row_idx] = []
                cells_by_row[row_idx].append(cell)

            # Sort cells within each row by column
            for row_idx in cells_by_row:
                cells_by_row[row_idx].sort(key=lambda c: c.get('col', 0))

            # Build rows structure with colspan/rowspan info and absolute col position
            rows_data = []
            for row_idx in range(num_rows):
                row_cells = []
                if row_idx in cells_by_row:
                    for cell in cells_by_row[row_idx]:
                        cell_content = cell.get('content', '')
                        row_span = cell.get('row_span', 1) or 1
                        col_span = cell.get('col_span', 1) or 1
                        col_idx = cell.get('col', 0)

                        row_cells.append({
                            'text': str(cell_content) if cell_content else '',
                            'rowspan': row_span,
                            'colspan': col_span,
                            'col': col_idx  # Absolute column position
                        })

                rows_data.append({'cells': row_cells})

            logger.debug(f"Built {num_rows} rows from cells dict with span info")
            return rows_data

        except Exception as e:
            logger.error(f"Error building rows from cells dict: {e}")
            return []

    def _draw_table_element_direct(
        self,
        pdf_canvas: canvas.Canvas,
        element: 'DocumentElement',
        page_height: float
    ):
        """
        Draw table element with Direct track positioning.

        Args:
            pdf_canvas: ReportLab canvas object
            element: DocumentElement with table content
            page_height: Page height for coordinate transformation
        """
        try:
            # Get table data - can be TableData object or dict from JSON
            rows_data = None

            if isinstance(element.content, TableData):
                # Direct TableData object - convert to HTML then parse
                html_content = element.content.to_html()
                parser = HTMLTableParser()
                parser.feed(html_content)
                if parser.tables and parser.tables[0]['rows']:
                    rows_data = parser.tables[0]['rows']
            elif isinstance(element.content, dict):
                # Dict from JSON - check if it has cells structure (from Direct extraction)
                if 'cells' in element.content:
                    # Build rows from cells structure directly (avoid HTML round-trip)
                    rows_data = self._build_rows_from_cells_dict(element.content)
                elif 'html' in element.content:
                    # Has HTML content - parse it
                    html_content = element.content['html']
                    parser = HTMLTableParser()
                    parser.feed(html_content)
                    if parser.tables and parser.tables[0]['rows']:
                        rows_data = parser.tables[0]['rows']

            if not rows_data:
                logger.warning(f"No table data for {element.element_id}")
                return

            rows = rows_data

            # Get bbox
            bbox = element.bbox
            if not bbox:
                logger.warning(f"No bbox for table {element.element_id}")
                return

            # Transform coordinates
            pdf_x = bbox.x0
            # Use exact bbox position (no buffer) - scaling will ensure table fits
            pdf_y = page_height - bbox.y1  # Bottom of table (ReportLab Y coordinate)

            table_width = bbox.x1 - bbox.x0
            table_height = bbox.y1 - bbox.y0

            # Create table
            from reportlab.platypus import Table, TableStyle
            from reportlab.lib import colors

            # Determine grid size from rows structure
            # Note: rows may have 'col' attribute for absolute positioning (from Direct extraction)
            # or may be sequential (from HTML parsing)
            num_rows = len(rows)

            # Check if cells have absolute column positions
            has_absolute_cols = any(
                'col' in cell
                for row in rows
                for cell in row['cells']
            )

            # Calculate actual number of columns
            max_cols = 0
            if has_absolute_cols:
                # Use absolute col positions + colspan to find max column
                for row in rows:
                    for cell in row['cells']:
                        col = cell.get('col', 0)
                        colspan = cell.get('colspan', 1)
                        max_cols = max(max_cols, col + colspan)
            else:
                # Sequential cells: sum up colspans
                for row in rows:
                    col_pos = 0
                    for cell in row['cells']:
                        colspan = cell.get('colspan', 1)
                        col_pos += colspan
                    max_cols = max(max_cols, col_pos)

            # Build table data for ReportLab with proper grid structure
            # ReportLab needs a full grid with placeholders for spanned cells
            # and SPAN commands to merge them
            table_content = []
            span_commands = []
            covered = set()  # Track cells covered by spans

            # First pass: mark covered cells and collect SPAN commands
            for row_idx, row in enumerate(rows):
                if has_absolute_cols:
                    # Use absolute column positions
                    for cell in row['cells']:
                        col_pos = cell.get('col', 0)
                        colspan = cell.get('colspan', 1)
                        rowspan = cell.get('rowspan', 1)

                        # Mark cells covered by this span
                        if colspan > 1 or rowspan > 1:
                            for r in range(row_idx, row_idx + rowspan):
                                for c in range(col_pos, col_pos + colspan):
                                    if (r, c) != (row_idx, col_pos):
                                        covered.add((r, c))
                            # Add SPAN command for ReportLab
                            span_commands.append((
                                'SPAN',
                                (col_pos, row_idx),
                                (col_pos + colspan - 1, row_idx + rowspan - 1)
                            ))
                else:
                    # Sequential positioning
                    col_pos = 0
                    for cell in row['cells']:
                        while (row_idx, col_pos) in covered:
                            col_pos += 1

                        colspan = cell.get('colspan', 1)
                        rowspan = cell.get('rowspan', 1)

                        if colspan > 1 or rowspan > 1:
                            for r in range(row_idx, row_idx + rowspan):
                                for c in range(col_pos, col_pos + colspan):
                                    if (r, c) != (row_idx, col_pos):
                                        covered.add((r, c))
                            span_commands.append((
                                'SPAN',
                                (col_pos, row_idx),
                                (col_pos + colspan - 1, row_idx + rowspan - 1)
                            ))
                        col_pos += colspan

            # Second pass: build content grid
            for row_idx in range(num_rows):
                row_data = [''] * max_cols

                if row_idx < len(rows):
                    if has_absolute_cols:
                        # Place cells at their absolute positions
                        for cell in rows[row_idx]['cells']:
                            col_pos = cell.get('col', 0)
                            if col_pos < max_cols:
                                row_data[col_pos] = cell['text'].strip()
                    else:
                        # Sequential placement
                        col_pos = 0
                        for cell in rows[row_idx]['cells']:
                            while col_pos < max_cols and (row_idx, col_pos) in covered:
                                col_pos += 1
                            if col_pos < max_cols:
                                row_data[col_pos] = cell['text'].strip()
                                colspan = cell.get('colspan', 1)
                                col_pos += colspan

                table_content.append(row_data)

            logger.debug(f"Built table grid: {num_rows} rows × {max_cols} cols, {len(span_commands)} span commands (absolute_cols={has_absolute_cols})")

            # Use original column widths from extraction if available
            # Otherwise try to compute from cell_boxes (from PP-StructureV3)
            col_widths = None
            if element.metadata and 'column_widths' in element.metadata:
                col_widths = element.metadata['column_widths']
                logger.debug(f"Using extracted column widths: {col_widths}")
            elif element.metadata and 'cell_boxes' in element.metadata:
                # Use cell_boxes from PP-StructureV3 for accurate column/row sizing
                cell_boxes = element.metadata['cell_boxes']
                cell_boxes_source = element.metadata.get('cell_boxes_source', 'unknown')
                table_bbox_list = [bbox.x0, bbox.y0, bbox.x1, bbox.y1]
                logger.info(f"[TABLE] Using {len(cell_boxes)} cell boxes from {cell_boxes_source}")

                computed_col_widths, computed_row_heights = self._compute_table_grid_from_cell_boxes(
                    cell_boxes, table_bbox_list, num_rows, max_cols
                )
                if computed_col_widths:
                    col_widths = computed_col_widths
                    logger.info(f"[TABLE] Computed {len(col_widths)} column widths from cell_boxes")

            # NOTE: Don't use rowHeights from extraction - it causes content overlap
            # The extracted row heights are based on cell boundaries, not text content height.
            # When text wraps or uses different font sizes, the heights don't match.
            # Let ReportLab auto-calculate row heights based on content, then use scaling
            # to fit within the bbox (same approach as old commit ba8ddf2b).

            # Create table without rowHeights - let ReportLab auto-calculate
            t = Table(table_content, colWidths=col_widths)

            # Apply style with minimal padding to reduce table extension
            # Use Chinese font to support special characters (℃, μm, ≦, ×, Ω, etc.)
            font_for_table = self.font_name if self.font_registered else 'Helvetica'
            style_commands = [
                ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
                ('FONTNAME', (0, 0), (-1, -1), font_for_table),
                ('FONTSIZE', (0, 0), (-1, -1), 8),
                ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
                ('VALIGN', (0, 0), (-1, -1), 'TOP'),
                # Set minimal padding to prevent table from extending beyond bbox
                # User reported padding=1 was still insufficient
                ('TOPPADDING', (0, 0), (-1, -1), 0),
                ('BOTTOMPADDING', (0, 0), (-1, -1), 0),
                ('LEFTPADDING', (0, 0), (-1, -1), 1),
                ('RIGHTPADDING', (0, 0), (-1, -1), 1),
            ]
            # Add span commands for merged cells
            style_commands.extend(span_commands)
            if span_commands:
                logger.info(f"Applied {len(span_commands)} SPAN commands for merged cells")

            style = TableStyle(style_commands)
            t.setStyle(style)

            # Use canvas scaling as fallback to fit table within bbox
            # With proper row heights, scaling should be minimal (close to 1.0)

            # Step 1: Wrap to get actual rendered size
            actual_width, actual_height = t.wrapOn(pdf_canvas, table_width * 10, table_height * 10)
            logger.info(f"Table natural size: {actual_width:.1f} × {actual_height:.1f}pt, bbox: {table_width:.1f} × {table_height:.1f}pt")

            # Step 2: Calculate scale factor to fit within bbox
            scale_x = table_width / actual_width if actual_width > table_width else 1.0
            scale_y = table_height / actual_height if actual_height > table_height else 1.0
            scale = min(scale_x, scale_y, 1.0)  # Never scale up, only down

            logger.info(f"Scale factor: {scale:.3f} (x={scale_x:.3f}, y={scale_y:.3f})")

            # Step 3: Draw with scaling using canvas transform
            pdf_canvas.saveState()
            pdf_canvas.translate(pdf_x, pdf_y)
            pdf_canvas.scale(scale, scale)
            t.drawOn(pdf_canvas, 0, 0)
            pdf_canvas.restoreState()

            logger.info(f"Drew table at ({pdf_x:.1f}, {pdf_y:.1f}) with scale {scale:.3f}, final size: {actual_width * scale:.1f} × {actual_height * scale:.1f}pt")

            logger.debug(f"Drew table element: {len(rows)} rows")

        except Exception as e:
            logger.error(f"Failed to draw table element {element.element_id}: {e}")

    def _draw_image_element_direct(
        self,
        pdf_canvas: canvas.Canvas,
        element: 'DocumentElement',
        page_height: float,
        result_dir: Path
    ):
        """
        Draw image element with Direct track positioning.

        Args:
            pdf_canvas: ReportLab canvas object
            element: DocumentElement with image content
            page_height: Page height for coordinate transformation
            result_dir: Directory containing image files
        """
        try:
            # Get image path
            image_path_str = self._get_image_path(element)
            if not image_path_str:
                logger.warning(f"No image path for element {element.element_id}")
                return

            # Construct full path to image
            # saved_path is relative to result_dir (e.g., "document_id_p1_img0.png")
            image_path = result_dir / image_path_str

            # Fallback for legacy data
            if not image_path.exists():
                image_path = result_dir / Path(image_path_str).name

            if not image_path.exists():
                logger.warning(f"Image not found: {image_path_str} (in {result_dir})")
                return

            # Get bbox
            bbox = element.bbox
            if not bbox:
                logger.warning(f"No bbox for image {element.element_id}")
                return

            # Transform coordinates
            pdf_x = bbox.x0
            pdf_y = page_height - bbox.y1  # Bottom of image

            image_width = bbox.x1 - bbox.x0
            image_height = bbox.y1 - bbox.y0

            # Draw image
            pdf_canvas.drawImage(
                str(image_path),
                pdf_x,
                pdf_y,
                width=image_width,
                height=image_height,
                preserveAspectRatio=True
            )

            logger.debug(f"Drew image: {image_path} (from: {image_path_str})")

        except Exception as e:
            logger.error(f"Failed to draw image element {element.element_id}: {e}")

    # ============================================================================
    # Reflow Layout PDF Generation
    # ============================================================================

    def _get_elements_in_reading_order(self, page_data: Dict) -> List[Dict]:
        """
        Get elements sorted by reading order.

        For OCR track: Uses explicit 'reading_order' array from JSON
        For Direct track: Uses implicit element list order (PyMuPDF sort=True)

        Args:
            page_data: Page dictionary containing 'elements' and optionally 'reading_order'

        Returns:
            List of elements in proper reading order
        """
        elements = page_data.get('elements', [])
        reading_order = page_data.get('reading_order')

        if reading_order and isinstance(reading_order, list):
            # OCR track: use explicit reading order
            ordered = []
            for idx in reading_order:
                if isinstance(idx, int) and 0 <= idx < len(elements):
                    ordered.append(elements[idx])
            # Add any elements not in reading_order at the end
            ordered_indices = set(reading_order)
            for i, elem in enumerate(elements):
                if i not in ordered_indices:
                    ordered.append(elem)
            return ordered
        else:
            # Direct track: elements already in reading order from PyMuPDF
            return elements

    def _get_reflow_styles(self) -> Dict[str, ParagraphStyle]:
        """Create consistent styles for reflow PDF generation."""
        base_styles = getSampleStyleSheet()
        font_name = self.font_name if self.font_registered else 'Helvetica'

        styles = {
            'Title': ParagraphStyle(
                'ReflowTitle',
                parent=base_styles['Normal'],
                fontName=font_name,
                fontSize=18,
                leading=22,
                spaceAfter=12,
                textColor=colors.black,
            ),
            'Heading1': ParagraphStyle(
                'ReflowH1',
                parent=base_styles['Normal'],
                fontName=font_name,
                fontSize=16,
                leading=20,
                spaceAfter=10,
                spaceBefore=12,
                textColor=colors.black,
            ),
            'Heading2': ParagraphStyle(
                'ReflowH2',
                parent=base_styles['Normal'],
                fontName=font_name,
                fontSize=14,
                leading=18,
                spaceAfter=8,
                spaceBefore=10,
                textColor=colors.black,
            ),
            'Body': ParagraphStyle(
                'ReflowBody',
                parent=base_styles['Normal'],
                fontName=font_name,
                fontSize=12,
                leading=16,
                spaceAfter=6,
                textColor=colors.black,
            ),
            'TableCell': ParagraphStyle(
                'ReflowTableCell',
                parent=base_styles['Normal'],
                fontName=font_name,
                fontSize=10,
                leading=13,
                textColor=colors.black,
            ),
            'Caption': ParagraphStyle(
                'ReflowCaption',
                parent=base_styles['Normal'],
                fontName=font_name,
                fontSize=10,
                leading=13,
                spaceAfter=8,
                textColor=colors.gray,
            ),
        }
        return styles

    def _create_reflow_table(self, table_data: Dict, styles: Dict) -> Optional[Table]:
        """
        Create a Platypus Table for reflow mode with merged cell support.

        Args:
            table_data: Table element dictionary with 'content' containing 'cells'
            styles: Style dictionary

        Returns:
            Platypus Table object or None
        """
        try:
            # Get content - cells are inside 'content' dict
            content = table_data.get('content', {})
            if not isinstance(content, dict):
                return None

            cells = content.get('cells', [])
            if not cells:
                return None

            # Determine grid dimensions
            num_rows = content.get('rows', 0)
            num_cols = content.get('cols', 0)

            if num_rows == 0 or num_cols == 0:
                # Calculate from cells
                for cell in cells:
                    row = cell.get('row', cell.get('row_index', 0))
                    col = cell.get('col', cell.get('col_index', 0))
                    row_span = cell.get('row_span', 1)
                    col_span = cell.get('col_span', 1)
                    num_rows = max(num_rows, row + row_span)
                    num_cols = max(num_cols, col + col_span)

            if num_rows == 0 or num_cols == 0:
                return None

            # Initialize grid with empty strings
            grid = [['' for _ in range(num_cols)] for _ in range(num_rows)]
            # Track which cells are covered by spans
            covered = [[False for _ in range(num_cols)] for _ in range(num_rows)]
            # Track span commands
            span_commands = []

            # Fill grid with cell content
            for cell in cells:
                row = cell.get('row', cell.get('row_index', 0))
                col = cell.get('col', cell.get('col_index', 0))
                row_span = cell.get('row_span', 1)
                col_span = cell.get('col_span', 1)

                # Get cell text
                text = cell.get('content', cell.get('text', ''))
                if not isinstance(text, str):
                    text = str(text) if text else ''

                # Escape HTML special characters
                text = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')

                # Place content in the top-left cell of the span
                if 0 <= row < num_rows and 0 <= col < num_cols:
                    grid[row][col] = text

                    # Mark covered cells for spans
                    if row_span > 1 or col_span > 1:
                        # Add SPAN command
                        span_commands.append((
                            'SPAN',
                            (col, row),
                            (col + col_span - 1, row + row_span - 1)
                        ))
                        # Mark cells as covered
                        for r in range(row, min(row + row_span, num_rows)):
                            for c in range(col, min(col + col_span, num_cols)):
                                if r != row or c != col:
                                    covered[r][c] = True

            # Build table data with Paragraphs
            data = []
            for row_idx in range(num_rows):
                row_data = []
                for col_idx in range(num_cols):
                    if covered[row_idx][col_idx]:
                        # Empty cell for covered spans
                        row_data.append('')
                    else:
                        text = grid[row_idx][col_idx]
                        row_data.append(Paragraph(text, styles['TableCell']))
                data.append(row_data)

            if not data:
                return None

            # Create table
            table = Table(data)

            # Build style commands
            style_commands = [
                ('GRID', (0, 0), (-1, -1), 0.5, colors.black),
                ('VALIGN', (0, 0), (-1, -1), 'TOP'),
                ('LEFTPADDING', (0, 0), (-1, -1), 6),
                ('RIGHTPADDING', (0, 0), (-1, -1), 6),
                ('TOPPADDING', (0, 0), (-1, -1), 4),
                ('BOTTOMPADDING', (0, 0), (-1, -1), 4),
                ('BACKGROUND', (0, 0), (-1, 0), colors.lightgrey),  # Header row
            ]

            # Add span commands
            style_commands.extend(span_commands)

            table.setStyle(TableStyle(style_commands))

            logger.info(f"[REFLOW TABLE] Created table with {num_rows}x{num_cols} cells, {len(span_commands)} spans")
            return table

        except Exception as e:
            logger.error(f"Failed to create reflow table: {e}")
            import traceback
            traceback.print_exc()
            return None

    def _embed_image_reflow(
        self,
        element: Dict,
        result_dir: Path,
        max_width: float = 450
    ) -> Optional[PlatypusImage]:
        """
        Embed an image for reflow mode.

        Args:
            element: Image element dictionary
            result_dir: Directory containing images
            max_width: Maximum width in points

        Returns:
            Platypus Image object or None
        """
        try:
            # Get image path - check multiple possible locations
            img_path_str = element.get('image_path', element.get('path', ''))

            # Also check content.saved_path (Direct track format)
            if not img_path_str:
                content = element.get('content', {})
                if isinstance(content, dict):
                    img_path_str = content.get('saved_path', content.get('path', ''))

            if not img_path_str:
                return None

            img_path = result_dir / img_path_str
            if not img_path.exists():
                # Try just the filename
                img_path = result_dir / Path(img_path_str).name
                if not img_path.exists():
                    logger.warning(f"Image not found for reflow: {img_path_str}")
                    return None

            # Create Platypus Image
            img = PlatypusImage(str(img_path))

            # Scale to fit page width if necessary
            if img.drawWidth > max_width:
                ratio = max_width / img.drawWidth
                img.drawWidth = max_width
                img.drawHeight *= ratio

            return img

        except Exception as e:
            logger.error(f"Failed to embed image for reflow: {e}")
            return None

    def generate_reflow_pdf(
        self,
        json_path: Path,
        output_path: Path,
        source_file_path: Optional[Path] = None
    ) -> bool:
        """
        Generate reflow layout PDF from OCR/Direct JSON data.

        This creates a flowing document with consistent font sizes,
        proper reading order, and inline tables/images.

        Args:
            json_path: Path to result JSON file (UnifiedDocument format)
            output_path: Path to save generated PDF
            source_file_path: Optional path to original source file (for images)

        Returns:
            True if successful, False otherwise
        """
        try:
            # Load JSON data
            logger.info(f"Generating reflow PDF from: {json_path}")
            with open(json_path, 'r', encoding='utf-8') as f:
                json_data = json.load(f)

            # Get styles
            styles = self._get_reflow_styles()

            # Build document content
            story = []
            # Use source_file_path if provided (for translated PDFs where JSON is in temp dir)
            # Otherwise use json_path.parent (for regular reflow PDFs)
            if source_file_path and source_file_path.is_dir():
                result_dir = source_file_path
            elif source_file_path and source_file_path.is_file():
                result_dir = source_file_path.parent
            else:
                result_dir = json_path.parent

            # Process each page
            pages = json_data.get('pages', [])
            for page_idx, page_data in enumerate(pages):
                if page_idx > 0:
                    # Add page break between pages
                    story.append(Spacer(1, 30))

                # Get elements in reading order
                elements = self._get_elements_in_reading_order(page_data)

                for elem in elements:
                    elem_type = elem.get('type', elem.get('element_type', 'text'))
                    content = elem.get('content', elem.get('text', ''))

                    # Types that can have dict content (handled specially)
                    dict_content_types = ('table', 'Table', 'image', 'figure', 'Image', 'Figure', 'chart', 'Chart')

                    # Ensure content is a string for text elements
                    if isinstance(content, dict):
                        # Tables, images, charts have dict content - handled by their respective methods
                        if elem_type not in dict_content_types:
                            # Skip other elements with dict content
                            continue
                    elif not isinstance(content, str):
                        content = str(content) if content else ''

                    if elem_type in ('table', 'Table'):
                        # Handle table
                        table = self._create_reflow_table(elem, styles)
                        if table:
                            story.append(table)
                            story.append(Spacer(1, 12))

                        # Handle embedded images in table (from metadata)
                        metadata = elem.get('metadata', {})
                        embedded_images = metadata.get('embedded_images', [])
                        for emb_img in embedded_images:
                            img_path_str = emb_img.get('saved_path', '')
                            if img_path_str:
                                img_path = result_dir / img_path_str
                                if not img_path.exists():
                                    img_path = result_dir / Path(img_path_str).name
                                if img_path.exists():
                                    try:
                                        img = PlatypusImage(str(img_path))
                                        # Scale to fit page width if necessary
                                        max_width = 450
                                        if img.drawWidth > max_width:
                                            ratio = max_width / img.drawWidth
                                            img.drawWidth = max_width
                                            img.drawHeight *= ratio
                                        story.append(img)
                                        story.append(Spacer(1, 8))
                                        logger.info(f"Embedded table image in reflow: {img_path.name}")
                                    except Exception as e:
                                        logger.warning(f"Failed to embed table image: {e}")

                    elif elem_type in ('image', 'figure', 'Image', 'Figure', 'chart', 'Chart'):
                        # Handle image/chart
                        img = self._embed_image_reflow(elem, result_dir)
                        if img:
                            story.append(img)
                            story.append(Spacer(1, 8))

                    elif elem_type in ('title', 'Title'):
                        # Title text
                        if content:
                            content = content.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
                            story.append(Paragraph(content, styles['Title']))

                    elif elem_type in ('section_header', 'SectionHeader', 'h1', 'H1'):
                        # Heading 1
                        if content:
                            content = content.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
                            story.append(Paragraph(content, styles['Heading1']))

                    elif elem_type in ('h2', 'H2', 'Heading2'):
                        # Heading 2
                        if content:
                            content = content.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
                            story.append(Paragraph(content, styles['Heading2']))

                    else:
                        # Body text (default)
                        if content:
                            content = content.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
                            story.append(Paragraph(content, styles['Body']))

            if not story:
                logger.warning("No content to generate reflow PDF")
                return False

            # Create PDF document
            doc = SimpleDocTemplate(
                str(output_path),
                pagesize=A4,
                leftMargin=50,
                rightMargin=50,
                topMargin=50,
                bottomMargin=50
            )

            # Build PDF
            doc.build(story)

            logger.info(f"Generated reflow PDF: {output_path} ({output_path.stat().st_size} bytes)")
            return True

        except Exception as e:
            logger.error(f"Failed to generate reflow PDF: {e}")
            import traceback
            traceback.print_exc()
            return False

    def generate_translated_pdf(
        self,
        result_json_path: Path,
        translation_json_path: Path,
        output_path: Path,
        source_file_path: Optional[Path] = None
    ) -> bool:
        """
        Generate reflow layout PDF with translated content.

        This method loads the original result JSON and translation JSON,
        merges them to replace original content with translations, and
        generates a PDF with the translated content at original positions.

        Args:
            result_json_path: Path to original result JSON file (UnifiedDocument format)
            translation_json_path: Path to translation JSON file
            output_path: Path to save generated translated PDF
            source_file_path: Optional path to original source file

        Returns:
            True if successful, False otherwise
        """
        import tempfile

        try:
            # Import apply_translations from translation service
            from app.services.translation_service import apply_translations

            # Load original result JSON
            logger.info(f"Loading result JSON: {result_json_path}")
            with open(result_json_path, 'r', encoding='utf-8') as f:
                result_json = json.load(f)

            # Load translation JSON
            logger.info(f"Loading translation JSON: {translation_json_path}")
            with open(translation_json_path, 'r', encoding='utf-8') as f:
                translation_json = json.load(f)

            # Extract translations dict from translation JSON
            translations = translation_json.get('translations', {})
            if not translations:
                logger.warning("No translations found in translation JSON")
                # Still generate PDF with original content as fallback
                return self.generate_layout_pdf(
                    json_path=result_json_path,
                    output_path=output_path,
                    source_file_path=source_file_path
                )

            # Apply translations to result JSON
            translated_doc = apply_translations(result_json, translations)

            target_lang = translation_json.get('target_lang', 'unknown')
            logger.info(
                f"Generating translated PDF: {len(translations)} translations applied, "
                f"target_lang={target_lang}"
            )

            # Write translated JSON to a temporary file and use reflow PDF generation
            with tempfile.NamedTemporaryFile(
                mode='w',
                suffix='_translated.json',
                delete=False,
                encoding='utf-8'
            ) as tmp_file:
                json.dump(translated_doc, tmp_file, ensure_ascii=False, indent=2)
                tmp_path = Path(tmp_file.name)

            try:
                # Use reflow PDF generation for better translated content display
                # Pass result_json_path.parent as image directory (not the temp file's parent)
                success = self.generate_reflow_pdf(
                    json_path=tmp_path,
                    output_path=output_path,
                    source_file_path=result_json_path.parent  # Contains extracted images
                )
                return success
            finally:
                # Clean up temporary file
                if tmp_path.exists():
                    tmp_path.unlink()

        except FileNotFoundError as e:
            logger.error(f"File not found: {e}")
            return False
        except json.JSONDecodeError as e:
            logger.error(f"Invalid JSON: {e}")
            return False
        except Exception as e:
            logger.error(f"Failed to generate translated PDF: {e}")
            import traceback
            traceback.print_exc()
            return False

    def generate_translated_layout_pdf(
        self,
        result_json_path: Path,
        translation_json_path: Path,
        output_path: Path,
        source_file_path: Optional[Path] = None
    ) -> bool:
        """
        Generate layout-preserving PDF with translated content.

        This method creates a PDF that maintains the original document layout
        while displaying translated text. Key features:
        - Text wraps within original bounding boxes (no font shrinking)
        - Tables adapt to translated content
        - Images and other elements remain at original positions
        - Font size is kept readable (minimum 10pt)

        Args:
            result_json_path: Path to original result JSON file
            translation_json_path: Path to translation JSON file
            output_path: Path to save generated translated PDF
            source_file_path: Optional path for image directory

        Returns:
            True if successful, False otherwise
        """
        import tempfile
        from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
        from reportlab.platypus import Paragraph, Frame
        from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT

        try:
            # Import apply_translations from translation service
            from app.services.translation_service import apply_translations

            # Load original result JSON
            logger.info(f"Loading result JSON for layout PDF: {result_json_path}")
            with open(result_json_path, 'r', encoding='utf-8') as f:
                result_json = json.load(f)

            # Load translation JSON
            logger.info(f"Loading translation JSON: {translation_json_path}")
            with open(translation_json_path, 'r', encoding='utf-8') as f:
                translation_json = json.load(f)

            # Extract translations dict
            translations = translation_json.get('translations', {})
            if not translations:
                logger.warning("No translations found, falling back to original layout PDF")
                return self.generate_layout_pdf(
                    json_path=result_json_path,
                    output_path=output_path,
                    source_file_path=source_file_path
                )

            # Apply translations to result JSON
            translated_doc = apply_translations(result_json, translations)

            target_lang = translation_json.get('target_lang', 'unknown')
            logger.info(
                f"Generating translated layout PDF: {len(translations)} translations, "
                f"target_lang={target_lang}"
            )

            # Determine image directory
            if source_file_path and source_file_path.is_dir():
                image_dir = source_file_path
            elif source_file_path and source_file_path.is_file():
                image_dir = source_file_path.parent
            else:
                image_dir = result_json_path.parent

            # Create PDF canvas
            from reportlab.pdfgen import canvas

            # Get page dimensions from first page
            pages = translated_doc.get('pages', [])
            if not pages:
                logger.error("No pages in document")
                return False

            first_page = pages[0]
            dims = first_page.get('dimensions', {})
            page_width = dims.get('width', 595.32)
            page_height = dims.get('height', 841.92)

            pdf_canvas = canvas.Canvas(str(output_path), pagesize=(page_width, page_height))

            # Create paragraph styles for text wrapping
            base_style = ParagraphStyle(
                'TranslatedBase',
                fontName=self.font_name if self.font_registered else 'Helvetica',
                fontSize=10,
                leading=12,
                wordWrap='CJK',  # Support CJK word wrapping
            )

            # Process each page
            for page_idx, page_data in enumerate(pages):
                logger.info(f"Processing translated layout page {page_idx + 1}/{len(pages)}")

                # Get current page dimensions
                dims = page_data.get('dimensions', {})
                current_page_width = dims.get('width', page_width)
                current_page_height = dims.get('height', page_height)

                if page_idx > 0:
                    pdf_canvas.showPage()

                pdf_canvas.setPageSize((current_page_width, current_page_height))

                # Process elements:
                # - Tables: draw borders + translated cell text (with dynamic font sizing)
                # - Text elements: draw at original positions, SKIP if inside table bbox
                # - Images: draw at original positions
                elements = page_data.get('elements', [])

                # Collect table bboxes to skip text elements inside tables
                table_bboxes = []
                for elem in elements:
                    if elem.get('type') in ('table', 'Table'):
                        elem_bbox = elem.get('bbox', {})
                        if elem_bbox:
                            table_bboxes.append(elem_bbox)

                def is_inside_table(text_bbox):
                    """Check if text bbox is inside any table bbox."""
                    margin = 5
                    for tb in table_bboxes:
                        if (text_bbox.get('x0', 0) >= tb.get('x0', 0) - margin and
                            text_bbox.get('y0', 0) >= tb.get('y0', 0) - margin and
                            text_bbox.get('x1', 0) <= tb.get('x1', 0) + margin and
                            text_bbox.get('y1', 0) <= tb.get('y1', 0) + margin):
                            return True
                    return False

                for elem in elements:
                    elem_type = elem.get('type', 'text')
                    content = elem.get('content', '')
                    bbox = elem.get('bbox', {})

                    if not bbox:
                        continue

                    x0 = bbox.get('x0', 0)
                    y0 = bbox.get('y0', 0)
                    x1 = bbox.get('x1', 0)
                    y1 = bbox.get('y1', 0)
                    box_width = x1 - x0
                    box_height = y1 - y0

                    if box_width <= 0 or box_height <= 0:
                        continue

                    # Handle different element types
                    if elem_type in ('image', 'figure', 'Image', 'Figure', 'chart', 'Chart'):
                        # Skip large vector_graphics charts - they're visual decorations that cover text
                        if elem_type in ('chart', 'Chart'):
                            elem_content = elem.get('content', {})
                            is_vector_graphics = (
                                isinstance(elem_content, dict) and
                                elem_content.get('source') == 'vector_graphics'
                            )
                            if is_vector_graphics:
                                page_area = current_page_width * current_page_height
                                elem_area = box_width * box_height
                                coverage_ratio = elem_area / page_area if page_area > 0 else 0
                                if coverage_ratio > 0.5:
                                    logger.info(f"Skipping large vector_graphics chart "
                                              f"(covers {coverage_ratio*100:.1f}% of page)")
                                    continue

                        # Draw image
                        img = self._embed_image_reflow(elem, image_dir)
                        if img:
                            # Convert to PDF coordinates
                            pdf_y = current_page_height - y1
                            # Scale image to fit bbox
                            scale = min(box_width / img.drawWidth, box_height / img.drawHeight)
                            img.drawWidth *= scale
                            img.drawHeight *= scale
                            img.drawOn(pdf_canvas, x0, pdf_y)

                    elif elem_type in ('table', 'Table'):
                        # Draw table with wrapping
                        self._draw_translated_table(
                            pdf_canvas, elem, current_page_height, image_dir
                        )

                    elif isinstance(content, str) and content.strip():
                        # Skip text elements inside table bboxes
                        # (Table cells are rendered by _draw_translated_table with dynamic font sizing)
                        if is_inside_table(bbox):
                            continue

                        # Text element - use Paragraph for word wrapping
                        # Escape special characters
                        safe_content = content.replace('&', '&amp;')
                        safe_content = safe_content.replace('<', '&lt;')
                        safe_content = safe_content.replace('>', '&gt;')
                        # Replace newlines with <br/>
                        safe_content = safe_content.replace('\n', '<br/>')

                        # Get original font size from style info
                        style_info = elem.get('style', {})
                        original_font_size = style_info.get('font_size', 12.0)

                        # Detect vertical text (Y-axis labels, etc.)
                        # Vertical text has aspect_ratio (height/width) > 2 and multiple characters
                        is_vertical_text = (
                            box_height > box_width * 2 and
                            len(content.strip()) > 1
                        )

                        if is_vertical_text:
                            # For vertical text, use original font size and rotate
                            font_size = min(original_font_size, box_width * 0.9)
                            font_size = max(font_size, 6)  # Minimum 6pt

                            # Save canvas state for rotation
                            pdf_canvas.saveState()

                            # Convert to PDF coordinates
                            pdf_y_center = current_page_height - (y0 + y1) / 2
                            x_center = (x0 + x1) / 2

                            # Translate to center, rotate, translate back
                            pdf_canvas.translate(x_center, pdf_y_center)
                            pdf_canvas.rotate(90)

                            # Set font and draw text centered
                            pdf_canvas.setFont(
                                self.font_name if self.font_registered else 'Helvetica',
                                font_size
                            )
                            # Draw text at origin (since we translated to center)
                            text_width = pdf_canvas.stringWidth(
                                safe_content.replace('&amp;', '&').replace('&lt;', '<').replace('&gt;', '>'),
                                self.font_name if self.font_registered else 'Helvetica',
                                font_size
                            )
                            pdf_canvas.drawString(-text_width / 2, -font_size / 3,
                                safe_content.replace('&amp;', '&').replace('&lt;', '<').replace('&gt;', '>'))

                            pdf_canvas.restoreState()
                        else:
                            # For horizontal text, dynamically fit text within bbox
                            # Start with original font size and reduce until text fits
                            MIN_FONT_SIZE = 6
                            MAX_FONT_SIZE = 14

                            if original_font_size > 0:
                                start_font_size = min(original_font_size, MAX_FONT_SIZE)
                            else:
                                start_font_size = min(box_height * 0.7, MAX_FONT_SIZE)

                            font_size = max(start_font_size, MIN_FONT_SIZE)

                            # Try progressively smaller font sizes until text fits
                            para = None
                            para_height = box_height + 1  # Start with height > box to enter loop

                            while font_size >= MIN_FONT_SIZE and para_height > box_height:
                                elem_style = ParagraphStyle(
                                    f'elem_{id(elem)}_{font_size}',
                                    parent=base_style,
                                    fontSize=font_size,
                                    leading=font_size * 1.15,  # Tighter leading
                                )

                                para = Paragraph(safe_content, elem_style)
                                para_width, para_height = para.wrap(box_width, box_height * 3)

                                if para_height <= box_height:
                                    break  # Text fits!

                                font_size -= 0.5  # Reduce font size and try again

                            # Ensure minimum font size
                            if font_size < MIN_FONT_SIZE:
                                font_size = MIN_FONT_SIZE
                                elem_style = ParagraphStyle(
                                    f'elem_{id(elem)}_min',
                                    parent=base_style,
                                    fontSize=font_size,
                                    leading=font_size * 1.15,
                                )
                                para = Paragraph(safe_content, elem_style)
                                para_width, para_height = para.wrap(box_width, box_height * 3)

                            # Convert to PDF coordinates (y from bottom)
                            # Clip to bbox height to prevent overflow
                            actual_height = min(para_height, box_height)
                            pdf_y = current_page_height - y0 - actual_height

                            # Draw the paragraph
                            para.drawOn(pdf_canvas, x0, pdf_y)

            # Save PDF
            pdf_canvas.save()
            logger.info(f"Translated layout PDF saved to {output_path}")
            return True

        except FileNotFoundError as e:
            logger.error(f"File not found: {e}")
            return False
        except json.JSONDecodeError as e:
            logger.error(f"Invalid JSON: {e}")
            return False
        except Exception as e:
            logger.error(f"Failed to generate translated layout PDF: {e}")
            import traceback
            traceback.print_exc()
            return False

    def _draw_translated_table(
        self,
        pdf_canvas,
        elem: Dict,
        page_height: float,
        image_dir: Path
    ):
        """
        Draw a table with translated content.

        Approach:
        1. Draw cell borders using cell_boxes from metadata
        2. Render translated text in each cell with dynamic font sizing
        3. Draw embedded images at their original positions

        Text is rendered with dynamic font sizing to fit within cells.
        Minimum font size is 6pt for readability.

        Args:
            pdf_canvas: ReportLab canvas
            elem: Table element dict with metadata containing cell_boxes
            page_height: Page height for coordinate transformation
            image_dir: Directory containing images
        """
        from reportlab.lib import colors
        from reportlab.lib.styles import ParagraphStyle
        from reportlab.platypus import Paragraph

        MIN_FONT_SIZE = 6  # Minimum font size for readability

        try:
            content = elem.get('content', {})
            bbox = elem.get('bbox', {})
            metadata = elem.get('metadata', {})

            if not bbox:
                return

            # Get table bounding box
            if isinstance(bbox, dict):
                tx0 = bbox.get('x0', 0)
                ty0 = bbox.get('y0', 0)
                tx1 = bbox.get('x1', 0)
                ty1 = bbox.get('y1', 0)
            else:
                tx0, ty0, tx1, ty1 = bbox[:4] if len(bbox) >= 4 else (0, 0, 0, 0)

            table_width = tx1 - tx0
            table_height = ty1 - ty0

            # Step 1: Draw outer table border
            pdf_canvas.setStrokeColor(colors.black)
            pdf_canvas.setLineWidth(1.0)
            pdf_y_bottom = page_height - ty1
            pdf_canvas.rect(tx0, pdf_y_bottom, table_width, table_height, stroke=1, fill=0)

            # Step 2: Get or calculate cell boxes
            cell_boxes = metadata.get('cell_boxes', [])

            # If no cell_boxes, calculate from column_widths and row_heights
            if not cell_boxes:
                column_widths = metadata.get('column_widths', [])
                row_heights = metadata.get('row_heights', [])

                if column_widths and row_heights:
                    # Calculate cell positions from widths and heights
                    cell_boxes = []
                    rows = content.get('rows', len(row_heights)) if isinstance(content, dict) else len(row_heights)
                    cols = content.get('cols', len(column_widths)) if isinstance(content, dict) else len(column_widths)

                    # Calculate cumulative positions
                    x_positions = [tx0]
                    for w in column_widths[:cols]:
                        x_positions.append(x_positions[-1] + w)

                    y_positions = [ty0]
                    for h in row_heights[:rows]:
                        y_positions.append(y_positions[-1] + h)

                    # Create cell boxes for each cell (row-major order)
                    for row_idx in range(rows):
                        for col_idx in range(cols):
                            if col_idx < len(x_positions) - 1 and row_idx < len(y_positions) - 1:
                                cx0 = x_positions[col_idx]
                                cy0 = y_positions[row_idx]
                                cx1 = x_positions[col_idx + 1]
                                cy1 = y_positions[row_idx + 1]
                                cell_boxes.append([cx0, cy0, cx1, cy1])

                    logger.debug(f"Calculated {len(cell_boxes)} cell boxes from {cols} cols x {rows} rows")

            # Normalize cell boxes for grid alignment
            if cell_boxes and hasattr(self, '_normalize_cell_boxes_to_grid'):
                cell_boxes = self._normalize_cell_boxes_to_grid(cell_boxes)

            # Draw cell borders
            if cell_boxes:
                pdf_canvas.setLineWidth(0.5)
                for box in cell_boxes:
                    if len(box) >= 4:
                        cx0, cy0, cx1, cy1 = box[:4]
                        cell_width = cx1 - cx0
                        cell_height = cy1 - cy0
                        pdf_cell_y = page_height - cy1
                        pdf_canvas.rect(cx0, pdf_cell_y, cell_width, cell_height, stroke=1, fill=0)

            # Step 3: Render translated text in each cell
            cells = content.get('cells', []) if isinstance(content, dict) else []
            font_name = self.font_name if self.font_registered else 'Helvetica'

            for i, cell in enumerate(cells):
                cell_text = cell.get('content', cell.get('text', ''))
                if not cell_text or not cell_text.strip():
                    continue

                # Get cell bounding box by index
                if i >= len(cell_boxes):
                    continue

                cx0, cy0, cx1, cy1 = cell_boxes[i][:4]
                cell_width = cx1 - cx0
                cell_height = cy1 - cy0

                # Skip tiny cells
                if cell_width < 10 or cell_height < 10:
                    continue

                # Prepare text (escape HTML special chars)
                safe_text = str(cell_text).replace('&', '&amp;')
                safe_text = safe_text.replace('<', '&lt;').replace('>', '&gt;')
                safe_text = safe_text.replace('\n', '<br/>')

                # Dynamic font sizing: start at 10pt, shrink until text fits
                padding = 3
                available_width = cell_width - padding * 2
                available_height = cell_height - padding * 2

                if available_width <= 0 or available_height <= 0:
                    continue

                # Try font sizes from 10pt down to MIN_FONT_SIZE
                for font_size in range(10, MIN_FONT_SIZE - 1, -1):
                    cell_style = ParagraphStyle(
                        f'cell_{i}_{font_size}',
                        fontName=font_name,
                        fontSize=font_size,
                        leading=font_size * 1.15,
                        wordWrap='CJK',
                    )
                    para = Paragraph(safe_text, cell_style)
                    para_width, para_height = para.wrap(available_width, available_height * 10)

                    if para_height <= available_height:
                        break  # Text fits at this font size

                # Draw text (centered vertically in cell)
                text_x = cx0 + padding
                # Calculate vertical position (top-aligned within cell)
                text_y = page_height - cy0 - padding - min(para_height, available_height)

                para.drawOn(pdf_canvas, text_x, text_y)

            logger.info(f"[TRANSLATED TABLE] Drew table with {len(cell_boxes)} borders, {len(cells)} cells")

            # Step 4: Draw embedded images
            embedded_images = metadata.get('embedded_images', [])
            if embedded_images and image_dir:
                for emb_img in embedded_images:
                    self._draw_embedded_image(
                        pdf_canvas, emb_img, page_height, image_dir, 1.0, 1.0
                    )

        except Exception as e:
            logger.error(f"Failed to draw translated table: {e}")
            import traceback
            traceback.print_exc()


# Singleton instance
pdf_generator_service = PDFGeneratorService()
