""" PDF Display Component Renders PDF documents using PDF.js for browser-side rendering. Supports span annotation on extracted text. Usage: In instance_display config: fields: - key: document type: pdf display_options: view_mode: scroll max_height: 700 text_layer: true """ from typing import Dict, Any, List, Optional import html import json import logging from .base import BaseDisplay logger = logging.getLogger(__name__) class PDFDisplay(BaseDisplay): """ Display type for PDF documents. Renders PDFs using PDF.js with an optional text layer for span annotation. Can display either a PDF URL or pre-extracted content. Supports two annotation modes: - span: Text selection and span annotation (default) - bounding_box: Draw bounding boxes on PDF pages """ name = "pdf" required_fields = ["key"] optional_fields = { "view_mode": "scroll", # "scroll", "paginated", or "side-by-side" "max_height": 700, # Max container height in pixels "max_width": None, # Max container width "text_layer": True, # Enable text selection layer "show_page_controls": True, # Show page navigation controls "initial_page": 1, # Page to display initially "zoom": "auto", # "auto", "page-fit", "page-width", or percentage "extracted_content": None, # Pre-extracted FormatOutput data "annotation_mode": "span", # "span" or "bounding_box" "bbox_min_size": 10, # Min bounding box size in pixels "bbox_colors": None, # Custom colors for bounding box labels "show_bbox_labels": True, # Show labels on bounding boxes } description = "PDF document display with PDF.js rendering" supports_span_target = False # Uses PDF.js text layer, not .text-content wrapper contract def render(self, field_config: Dict[str, Any], data: Any) -> str: """ Render a PDF document. Args: field_config: Display configuration data: Either a PDF file path/URL or a dictionary with extracted content Returns: HTML string for rendering """ options = self.get_display_options(field_config) annotation_mode = options.get("annotation_mode", "span") # Check if data is pre-extracted content or a file path if isinstance(data, dict): # Pre-extracted content from format handler if annotation_mode == "bounding_box": return self._render_extracted_bbox(data, options, field_config) return self._render_extracted(data, options, field_config) else: # File path or URL - render with PDF.js if annotation_mode == "bounding_box": return self._render_pdfjs_bbox(str(data), options, field_config) return self._render_pdfjs(str(data), options, field_config) def _render_pdfjs( self, pdf_source: str, options: Dict[str, Any], field_config: Dict[str, Any] ) -> str: """ Render PDF using PDF.js viewer. """ field_key = field_config.get("key", "pdf") view_mode = options.get("view_mode", "scroll") max_height = options.get("max_height", 700) max_width = options.get("max_width") text_layer = options.get("text_layer", True) show_controls = options.get("show_page_controls", True) zoom = options.get("zoom", "auto") initial_page = options.get("initial_page", 1) # Build style string styles = [] if max_height: styles.append(f"max-height: {max_height}px") if max_width: styles.append(f"max-width: {max_width}px") style_str = "; ".join(styles) if styles else "" # Build container parts = [] # Container div with PDF.js viewer parts.append( f'
' ) # Page controls if show_controls: parts.append('''
1 / -
''') # Canvas container for PDF.js parts.append('''
''') # Text layer for selection (if enabled) if text_layer: parts.append('
') parts.append('
') # Close canvas container # Loading indicator parts.append('''
Loading PDF...
''') # Error display parts.append('') parts.append('
') # Close main container return "\n".join(parts) def _render_extracted( self, content: Dict[str, Any], options: Dict[str, Any], field_config: Dict[str, Any] ) -> str: """ Render pre-extracted PDF content as HTML. This is used when the PDF has already been processed by the format handler and we want to display the extracted text. """ field_key = field_config.get("key", "pdf") max_height = options.get("max_height", 700) # Check if this is FormatOutput-style content if "rendered_html" in content: inner_html = content["rendered_html"] elif "text" in content: # Fall back to plain text inner_html = f'
{html.escape(content["text"])}
' else: inner_html = '
No content available
' # Build container style_str = f"max-height: {max_height}px; overflow-y: auto;" if max_height else "" # Add metadata if available metadata_html = "" if "metadata" in content: meta = content["metadata"] if "total_pages" in meta: metadata_html = f'
Pages: {meta["total_pages"]}
' return f'''
{metadata_html}
{inner_html}
''' def _render_pdfjs_bbox( self, pdf_source: str, options: Dict[str, Any], field_config: Dict[str, Any] ) -> str: """ Render PDF with bounding box annotation support. Uses paginated view mode by default for better bbox drawing experience. """ field_key = field_config.get("key", "pdf") # Force paginated mode for bounding box annotation view_mode = "paginated" max_height = options.get("max_height", 700) max_width = options.get("max_width") show_controls = options.get("show_page_controls", True) zoom = options.get("zoom", "page-fit") initial_page = options.get("initial_page", 1) bbox_min_size = options.get("bbox_min_size", 10) show_labels = options.get("show_bbox_labels", True) # Build style string styles = [] if max_height: styles.append(f"max-height: {max_height}px") if max_width: styles.append(f"max-width: {max_width}px") style_str = "; ".join(styles) if styles else "" parts = [] # Container div with bbox annotation mode parts.append( f'
' ) # Enhanced page controls for paginated navigation if show_controls: parts.append('''
Page of -
''') # Canvas container for PDF rendering and bbox overlay parts.append('''
''') # Bounding box info panel parts.append('''
Boxes on page: 0
Total boxes: 0
''') # Loading indicator parts.append('''
Loading PDF...
''') # Error display parts.append('') # Hidden input that carries drawn boxes through the standard save # pipeline (collected as "{name}:::_data", restored by the server). F-040. parts.append( f'' ) parts.append('
') # Close main container return "\n".join(parts) def _render_extracted_bbox( self, content: Dict[str, Any], options: Dict[str, Any], field_config: Dict[str, Any] ) -> str: """ Render pre-extracted PDF content with bounding box support. For pre-extracted content, we render page images for bbox annotation. """ field_key = field_config.get("key", "pdf") max_height = options.get("max_height", 700) bbox_min_size = options.get("bbox_min_size", 10) show_labels = options.get("show_bbox_labels", True) metadata = content.get("metadata", {}) total_pages = metadata.get("total_pages", 1) pages = metadata.get("pages", []) style_str = f"max-height: {max_height}px; overflow-y: auto;" if max_height else "" parts = [] parts.append( f'
' ) # Page navigation parts.append(f'''
Page of {total_pages}
''') # Render each page with bbox overlay if "rendered_html" in content: # Content has pre-rendered HTML parts.append('
') parts.append(content["rendered_html"]) parts.append('
') else: # Render pages from metadata parts.append('
') for i, page_meta in enumerate(pages, start=1): page_width = page_meta.get("width", 612) page_height = page_meta.get("height", 792) parts.append( f'
' ) parts.append(f'
Page {i}
') parts.append('
') parts.append(f'') parts.append('
') parts.append('
') # Bounding box info parts.append('''
Boxes on page: 0
Total boxes: 0
''') # Hidden input that carries drawn boxes through the standard save # pipeline (collected as "{name}:::_data", restored by the server). F-040. parts.append( f'' ) parts.append('
') return "\n".join(parts) def get_css_classes(self, field_config: Dict[str, Any]) -> List[str]: """Get CSS classes for the display container.""" classes = super().get_css_classes(field_config) options = self.get_display_options(field_config) if field_config.get("span_target"): classes.append("span-target-pdf") view_mode = options.get("view_mode", "scroll") classes.append(f"pdf-mode-{view_mode}") annotation_mode = options.get("annotation_mode", "span") if annotation_mode == "bounding_box": classes.append("pdf-bbox-annotation") return classes def get_data_attributes( self, field_config: Dict[str, Any], data: Any ) -> Dict[str, str]: """Get data attributes for JavaScript initialization.""" attrs = super().get_data_attributes(field_config, data) options = self.get_display_options(field_config) attrs["view-mode"] = options.get("view_mode", "scroll") attrs["text-layer"] = str(options.get("text_layer", True)).lower() return attrs def get_js_init(self) -> Optional[str]: """ Return JavaScript initialization code for PDF.js. """ return ''' // PDF display initialization is handled by pdf-viewer.js // which is loaded as a separate script if (typeof initPDFViewers === 'function') { initPDFViewers(); } ''' def validate_config(self, field_config: Dict[str, Any]) -> List[str]: """Validate the field configuration.""" errors = super().validate_config(field_config) options = field_config.get("display_options", {}) # Validate view_mode valid_modes = ["scroll", "paginated", "side-by-side"] view_mode = options.get("view_mode", "scroll") if view_mode not in valid_modes: errors.append( f"Invalid view_mode '{view_mode}'. " f"Must be one of: {', '.join(valid_modes)}" ) # Validate annotation_mode valid_annotation_modes = ["span", "bounding_box"] annotation_mode = options.get("annotation_mode", "span") if annotation_mode not in valid_annotation_modes: errors.append( f"Invalid annotation_mode '{annotation_mode}'. " f"Must be one of: {', '.join(valid_annotation_modes)}" ) # Validate zoom zoom = options.get("zoom", "auto") valid_zoom = ["auto", "page-fit", "page-width"] if zoom not in valid_zoom: try: float(zoom) except (TypeError, ValueError): errors.append( f"Invalid zoom '{zoom}'. " f"Must be one of {valid_zoom} or a number" ) return errors