"""Plot file processing and HTML rendering.""" import shutil import subprocess from pathlib import Path from typing import Any, Dict, Optional from jinja2 import Template from gallery.config import GalleryConfig from gallery.utils.metadata import ( get_metadata_file_path, resolve_metadata_for_plot, ) from gallery.utils.stats import ( calculate_directory_stats, format_file_size, ) try: import fitz # PyMuPDF _PYMUPDF_AVAILABLE = True except ImportError: _PYMUPDF_AVAILABLE = False _IMAGEMAGICK_AVAILABLE = shutil.which("convert") is not None def process_html_file(html_file: Path, web_dir: Path, current_metadata: Optional[Dict[str, Any]] = None) -> dict: """ Process HTML plot file, copying it to web directory. Args: html_file: Path to the source HTML file web_dir: Target web directory current_metadata: Current metadata dictionary for the plot Returns: Dictionary containing plot information """ web_html = web_dir / html_file.name if needs_update(html_file, web_html): shutil.copy2(html_file, web_html) # Get source file creation time source_creation_time = int(html_file.stat().st_ctime) # Resolve metadata if provided plot_metadata = {} if current_metadata is not None: plot_metadata = resolve_metadata_for_plot(html_file, current_metadata) return { "name": html_file.stem, "html_href": html_file.name, "is_html": True, "metadata": plot_metadata, "creation_time": source_creation_time, } def process_plot_files( config: GalleryConfig, plot_file: Path, web_dir: Path, current_metadata: Optional[Dict[str, Any]] = None, ) -> dict: """ Process plot files (PDF/PNG or HTML), handling conversion and copying. Args: config: Gallery configuration object plot_file: Path to the source plot file (PDF or HTML) web_dir: Target web directory current_metadata: Current metadata dictionary for the plot Returns: Dictionary containing plot information """ if plot_file.suffix.lower() == ".html": return process_html_file(plot_file, web_dir, current_metadata) # Handle PDF files png_file = plot_file.with_suffix(".png") web_pdf = web_dir / plot_file.name web_png = web_dir / png_file.name if needs_update(plot_file, web_pdf): shutil.copy2(plot_file, web_pdf) if not png_file.exists(): convert_pdf_to_png(plot_file, config=config) if needs_update(png_file, web_png): shutil.copy2(png_file, web_png) source_creation_time = int(plot_file.stat().st_ctime) plot_metadata = {} if current_metadata is not None: plot_metadata = resolve_metadata_for_plot(plot_file, current_metadata) return { "name": plot_file.stem, "pdf_href": plot_file.name, "png_href": png_file.name, "is_html": False, "metadata": plot_metadata, "creation_time": source_creation_time, } def render_gallery_page( config: GalleryConfig, template: Template, web_dir: Path, items: list, subdirs: list, relative_path: Path, title: Optional[str] = None, metadata: Optional[dict] = None, ) -> None: """ Unified template rendering for all gallery pages. Args: config: Gallery configuration object template: Jinja2 template object web_dir: Target web directory items: List of plot items subdirs: List of subdirectory names relative_path: Relative path from gallery root title: Page title (optional) metadata: Metadata dictionary (optional) """ if title is None: title = "Gallery" if relative_path == Path(".") else f"Gallery: {relative_path}" if metadata is None: metadata = {} # Calculate statistics current_stats = calculate_directory_stats(web_dir) stats = { "file_count": len(items), "folder_count": len(subdirs), "total_size": format_file_size(current_stats["total_size"]), "total_size_bytes": current_stats["total_size"], } # Calculate relative path to assets if relative_path == Path("."): assets_path = "../assets" else: depth = len(relative_path.parts) assets_path = "../" * (depth + 1) + "assets" # For root level, show only directory structure if relative_path == Path("."): items = [] output_html = web_dir / "index.html" with output_html.open("w") as f: paths_dict = { "web_folder": str(config.web_folder), } ui_dict = { "max_recent_plots": 20, "search_debounce_ms": 300, } rendered_html = template.render( title=title, items=items, subdirs=subdirs, relpath=str(relative_path), paths=paths_dict, ui=ui_dict, stats=stats, folder_metadata=metadata, assets_path=assets_path, source_dir=str(web_dir), metadata_file_path=get_metadata_file_path(web_dir), ) f.write(rendered_html) def convert_pdf_to_png(pdf_path: Path, config: GalleryConfig) -> None: """ Convert a PDF file to PNG format. Uses PyMuPDF (fitz) when available; falls back to ImageMagick otherwise. Only converts if the PNG doesn't exist or if the PDF is newer than the PNG (with a 30-second buffer to handle filesystem timing issues). Raises: RuntimeError: If neither PyMuPDF nor ImageMagick is available. subprocess.CalledProcessError: If the ImageMagick fallback fails. """ png_path = pdf_path.with_suffix(".png") if png_path.exists(): pdf_mtime = pdf_path.stat().st_mtime png_mtime = png_path.stat().st_mtime if png_mtime >= (pdf_mtime + 30): return if _PYMUPDF_AVAILABLE: _convert_pdf_pymupdf(pdf_path, png_path, config.png_dpi) elif _IMAGEMAGICK_AVAILABLE: _convert_pdf_imagemagick(pdf_path, png_path, config.png_dpi) else: raise RuntimeError( "No PDF renderer found. Install PyMuPDF (`pip install pymupdf`) " "or ImageMagick (`apt-get install imagemagick`)." ) def _convert_pdf_pymupdf(pdf_path: Path, png_path: Path, dpi: int) -> None: zoom = dpi / 72 # PDF coordinate space is 72 pt/inch doc = fitz.open(str(pdf_path)) try: pix = doc[0].get_pixmap(matrix=fitz.Matrix(zoom, zoom)) pix.save(str(png_path)) finally: doc.close() def _convert_pdf_imagemagick(pdf_path: Path, png_path: Path, dpi: int) -> None: subprocess.run( [ "convert", "-density", str(dpi), str(pdf_path), "-quality", "95", str(png_path), ], check=True, ) def needs_update(source_file: Path, target_file: Path) -> bool: """ Check if target file needs updating based on source modification time. Args: source_file: Path to the source file target_file: Path to the target file Returns: True if target needs update, False otherwise """ if not target_file.exists(): return True source_mtime = source_file.stat().st_mtime target_mtime = target_file.stat().st_mtime return source_mtime > (target_mtime + 30)