diff --git a/assets/css/export.css b/assets/css/export.css new file mode 100644 index 0000000..80fcc6e --- /dev/null +++ b/assets/css/export.css @@ -0,0 +1,289 @@ +/* ======================================== + EXPORT FUNCTIONALITY STYLES + ======================================== */ + +/* Selection mode styles */ +.selection-mode .grid-item { + cursor: pointer; + transition: all 0.2s ease; +} + +.selection-mode .grid-item:hover { + transform: scale(1.02); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +/* Selection overlay */ +.selection-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.3); + display: none; + justify-content: center; + align-items: center; + border-radius: 8px; + z-index: 5; +} + +.selection-mode .selection-overlay { + display: flex; +} + +.selection-checkbox { + background: var(--card-background); + border: 2px solid var(--border-color); + border-radius: 50%; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + transition: all 0.2s ease; +} + +.grid-item.selected .selection-checkbox { + background: var(--primary-color, #007bff); + border-color: var(--primary-color, #007bff); + color: white; +} + +.checkbox-icon { + line-height: 1; +} + +/* Selection counter */ +.selection-counter { + position: fixed; + bottom: 100px; + right: 20px; + background: var(--card-background); + border: 1px solid var(--border-color); + border-radius: 20px; + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + color: var(--text-color); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 999; +} + +/* Export button styles */ +.export-btn { + background: #28a745 !important; +} + +.export-btn:hover { + background: #218838 !important; +} + +.export-btn:disabled { + background: #6c757d !important; + cursor: not-allowed; +} + +/* Export messages */ +.export-message { + position: fixed; + top: 20px; + right: 20px; + padding: 12px 24px; + border-radius: 6px; + font-weight: 500; + z-index: 1001; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + animation: slideIn 0.3s ease; +} + +.export-message-info { + background: #d1ecf1; + color: #0c5460; + border: 1px solid #bee5eb; +} + +.export-message-success { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.export-message-warning { + background: #fff3cd; + color: #856404; + border: 1px solid #ffeaa7; +} + +.export-message-error { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Selection mode indicator */ +.selection-mode::before { + content: "Selection Mode - Click plots to select them"; + position: fixed; + top: 0; + left: 0; + right: 0; + background: var(--primary-color, #007bff); + color: white; + text-align: center; + padding: 8px; + font-size: 14px; + font-weight: 500; + z-index: 1000; +} + +/* Adjust main content when in selection mode */ +.selection-mode { + padding-top: 40px; +} + +/* Export instructions overlay */ +.export-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1002; +} + +.export-instructions { + background: var(--card-background); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 24px; + max-width: 600px; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); +} + +.export-instructions h3 { + margin: 0 0 16px 0; + color: var(--text-color); +} + +.export-instructions p { + margin: 0 0 16px 0; + color: var(--text-color); +} + +.export-data { + margin: 16px 0; +} + +.export-data textarea { + width: 100%; + height: 200px; + font-family: 'Courier New', monospace; + font-size: 12px; + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 8px; + background: var(--background-color); + color: var(--text-color); + resize: vertical; +} + +.export-commands { + margin: 16px 0; + padding: 12px; + background: var(--header-background); + border-radius: 4px; + border: 1px solid var(--border-color); +} + +.export-commands code { + background: var(--background-color); + padding: 4px 8px; + border-radius: 3px; + font-family: 'Courier New', monospace; + border: 1px solid var(--border-color); + display: inline-block; + margin-top: 8px; +} + +.export-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 20px; +} + +.export-actions button { + padding: 8px 16px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--card-background); + color: var(--text-color); + cursor: pointer; + transition: all 0.2s ease; +} + +.export-actions button:hover { + background: var(--header-background); +} + +.export-actions button:last-child { + background: var(--primary-color, #007bff); + color: white; + border-color: var(--primary-color, #007bff); +} + +.export-actions button:last-child:hover { + background: var(--primary-color-dark, #0056b3); +} +[data-theme="dark"] .selection-checkbox { + background: var(--card-background); + border-color: var(--border-color); +} + +[data-theme="dark"] .grid-item.selected .selection-checkbox { + background: var(--primary-color, #0d6efd); + border-color: var(--primary-color, #0d6efd); +} + +[data-theme="dark"] .export-message-info { + background: #0c5460; + color: #d1ecf1; + border-color: #086972; +} + +[data-theme="dark"] .export-message-success { + background: #155724; + color: #d4edda; + border-color: #1e7e34; +} + +[data-theme="dark"] .export-message-warning { + background: #856404; + color: #fff3cd; + border-color: #b58b14; +} + +[data-theme="dark"] .export-message-error { + background: #721c24; + color: #f8d7da; + border-color: #a94442; +} diff --git a/assets/css/main.css b/assets/css/main.css index f68a4c9..32a44c7 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -16,6 +16,7 @@ @import url('./stats.css'); @import url('./comparison.css'); @import url('./metadata.css'); +@import url('./export.css'); /* Responsive design */ @import url('./responsive.css'); diff --git a/assets/js/export-manager.js b/assets/js/export-manager.js new file mode 100644 index 0000000..ad9daeb --- /dev/null +++ b/assets/js/export-manager.js @@ -0,0 +1,320 @@ +/** + * Export Manager for Gallery + * Handles exporting selected plots to merged PDF + */ + +export class ExportManager { + constructor() { + this.selectedPlots = new Set(); + this.maxPlots = 4; + this.init(); + } + + init() { + this.createExportButton(); + this.bindEvents(); + } + + /** + * Create the export button in the floating buttons section + */ + createExportButton() { + const floatingButtons = document.querySelector('.floating-buttons'); + if (!floatingButtons) return; + + const exportBtn = document.createElement('button'); + exportBtn.className = 'floating-btn export-btn'; + exportBtn.id = 'exportBtn'; + exportBtn.title = 'Export Selected Plots (Ctrl+E)'; + exportBtn.innerHTML = '📄'; + exportBtn.style.display = 'none'; // Hidden by default + exportBtn.onclick = () => this.exportSelectedPlots(); + + floatingButtons.appendChild(exportBtn); + + // Add selection counter + const selectionCounter = document.createElement('div'); + selectionCounter.className = 'selection-counter'; + selectionCounter.id = 'selectionCounter'; + selectionCounter.style.display = 'none'; + selectionCounter.innerHTML = '0/4 selected'; + floatingButtons.appendChild(selectionCounter); + } + + /** + * Bind events for plot selection + */ + bindEvents() { + // Add selection mode toggle + document.addEventListener('keydown', (e) => { + if (e.ctrlKey && e.key === 'e') { + e.preventDefault(); + this.toggleSelectionMode(); + } + if (e.key === 'Escape') { + this.clearSelection(); + } + }); + + // Add selection handlers to existing plots + this.addSelectionHandlers(); + } + + /** + * Add selection handlers to all plot items + */ + addSelectionHandlers() { + const plotItems = document.querySelectorAll('.grid-item'); + plotItems.forEach(item => this.addSelectionHandler(item)); + } + + /** + * Add selection handler to a single plot item + */ + addSelectionHandler(item) { + // Create selection overlay + const overlay = document.createElement('div'); + overlay.className = 'selection-overlay'; + overlay.innerHTML = ` +
+ +
+ `; + item.appendChild(overlay); + + // Add click handler for selection + overlay.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.togglePlotSelection(item); + }); + } + + /** + * Toggle selection mode + */ + toggleSelectionMode() { + const body = document.body; + const isSelectionMode = body.classList.contains('selection-mode'); + + if (isSelectionMode) { + this.exitSelectionMode(); + } else { + this.enterSelectionMode(); + } + } + + /** + * Enter selection mode + */ + enterSelectionMode() { + document.body.classList.add('selection-mode'); + document.getElementById('exportBtn').style.display = 'block'; + document.getElementById('selectionCounter').style.display = 'block'; + this.updateSelectionCounter(); + } + + /** + * Exit selection mode + */ + exitSelectionMode() { + document.body.classList.remove('selection-mode'); + document.getElementById('exportBtn').style.display = 'none'; + document.getElementById('selectionCounter').style.display = 'none'; + this.clearSelection(); + } + + /** + * Toggle plot selection + */ + togglePlotSelection(item) { + const plotName = this.getPlotName(item); + const plotPath = this.getPlotPath(item); + + if (this.selectedPlots.has(plotName)) { + this.selectedPlots.delete(plotName); + item.classList.remove('selected'); + item.querySelector('.checkbox-icon').textContent = '☐'; + } else { + if (this.selectedPlots.size >= this.maxPlots) { + this.showMessage(`Maximum ${this.maxPlots} plots can be selected`, 'warning'); + return; + } + this.selectedPlots.add(plotName); + item.classList.add('selected'); + item.querySelector('.checkbox-icon').textContent = '☑'; + } + + this.updateSelectionCounter(); + } + + /** + * Get plot name from grid item + */ + getPlotName(item) { + const plotName = item.querySelector('.plot-name'); + return plotName ? plotName.textContent.trim() : ''; + } + + /** + * Get plot PDF path from grid item + */ + getPlotPath(item) { + const link = item.querySelector('a[href$=".pdf"]'); + return link ? link.href : ''; + } + + /** + * Update selection counter + */ + updateSelectionCounter() { + const counter = document.getElementById('selectionCounter'); + if (counter) { + counter.textContent = `${this.selectedPlots.size}/${this.maxPlots} selected`; + } + + const exportBtn = document.getElementById('exportBtn'); + if (exportBtn) { + exportBtn.disabled = this.selectedPlots.size === 0; + exportBtn.style.opacity = this.selectedPlots.size === 0 ? '0.5' : '1'; + } + } + + /** + * Clear all selections + */ + clearSelection() { + this.selectedPlots.clear(); + document.querySelectorAll('.grid-item.selected').forEach(item => { + item.classList.remove('selected'); + const checkbox = item.querySelector('.checkbox-icon'); + if (checkbox) checkbox.textContent = '☐'; + }); + this.updateSelectionCounter(); + } + + /** + * Export selected plots to merged PDF + */ + async exportSelectedPlots() { + if (this.selectedPlots.size === 0) { + this.showMessage('No plots selected', 'warning'); + return; + } + + const plotPaths = Array.from(this.selectedPlots).map(plotName => { + const item = Array.from(document.querySelectorAll('.grid-item')) + .find(item => this.getPlotName(item) === plotName); + return this.getPlotPath(item); + }); + + this.showMessage('Preparing export...', 'info'); + + try { + await this.createMergedPDF(plotPaths); + } catch (error) { + this.showMessage('Export failed: ' + error.message, 'error'); + } + } + + /** + * Create merged PDF using Python script + */ + async createMergedPDF(plotPaths) { + // Convert file:// URLs to actual paths + const actualPaths = plotPaths.map(url => { + if (url.startsWith('file://')) { + return url.substring(7); // Remove 'file://' prefix + } + return url; + }); + + const payload = { + plots: actualPaths, + layout: this.calculateLayout(actualPaths.length), + output_name: `merged_plots_${new Date().toISOString().split('T')[0]}.pdf` + }; + + // Save the request to a JSON file that can be picked up by a Python script + const requestData = JSON.stringify(payload, null, 2); + + // Since we can't directly call Python from the browser, we'll show instructions + this.showExportInstructions(requestData); + } + + /** + * Calculate optimal layout for given number of plots + */ + calculateLayout(numPlots) { + switch (numPlots) { + case 1: return { rows: 1, cols: 1 }; + case 2: return { rows: 1, cols: 2 }; + case 3: return { rows: 2, cols: 2 }; // 3 plots in 2x2 grid with one empty + case 4: return { rows: 2, cols: 2 }; + default: return { rows: 2, cols: 2 }; + } + } + + /** + * Show export instructions to user + */ + showExportInstructions(requestData) { + const instructions = ` +
+

Export Instructions

+

To export your selected plots, save the following data to a file called export_request.json and run the export script:

+
+ +
+
+

Command to run:

+ cd /work/kschmidt/web && python export_plots.py export_request.json +
+
+ + +
+
+ `; + + const overlay = document.createElement('div'); + overlay.className = 'export-overlay'; + overlay.innerHTML = instructions; + document.body.appendChild(overlay); + } + + /** + * Download the PDF blob + */ + downloadPDF(blob) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `merged_plots_${new Date().toISOString().split('T')[0]}.pdf`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + /** + * Show temporary message to user + */ + showMessage(text, type = 'info') { + // Remove existing message + const existing = document.querySelector('.export-message'); + if (existing) existing.remove(); + + const message = document.createElement('div'); + message.className = `export-message export-message-${type}`; + message.textContent = text; + + document.body.appendChild(message); + + setTimeout(() => { + if (message.parentNode) { + message.parentNode.removeChild(message); + } + }, 3000); + } +} diff --git a/assets/js/gallery-app.js b/assets/js/gallery-app.js index 01eb39e..935294e 100644 --- a/assets/js/gallery-app.js +++ b/assets/js/gallery-app.js @@ -9,6 +9,7 @@ import { RecentPlotsManager } from './recent-plots-manager.js'; import { ComparisonManager } from './comparison-manager.js'; import { StatsManager } from './stats-manager.js'; import { KeyboardManager } from './keyboard-manager.js'; +import { ExportManager } from './export-manager.js'; import { Utils } from './utils.js'; /** @@ -28,6 +29,7 @@ export class GalleryApp { this.recentPlotsManager = new RecentPlotsManager(this.MAX_RECENT_PLOTS); this.comparisonManager = new ComparisonManager(); this.statsManager = new StatsManager(); + this.exportManager = new ExportManager(); this.keyboardManager = new KeyboardManager(this); this.utils = Utils; @@ -36,6 +38,7 @@ export class GalleryApp { window.searchManager = this.searchManager; window.recentPlotsManager = this.recentPlotsManager; window.comparisonManager = this.comparisonManager; + window.exportManager = this.exportManager; window.utils = this.utils; this.init(); diff --git a/docs/EXPORT_FUNCTIONALITY.md b/docs/EXPORT_FUNCTIONALITY.md new file mode 100644 index 0000000..96669e7 --- /dev/null +++ b/docs/EXPORT_FUNCTIONALITY.md @@ -0,0 +1,101 @@ +# PDF Export Functionality + +The gallery now includes the ability to export multiple plots to a merged PDF with grid layout. + +## Features + +- Select up to 4 plots from any gallery page +- Automatic grid layout (1x1, 1x2, 2x2) +- Export to PDF with proper scaling +- Keyboard shortcuts for easy access + +## Usage + +### Selecting Plots + +1. Press **Ctrl+E** to enter selection mode +2. Click on up to 4 plot thumbnails to select them +3. Selected plots will show a checkmark overlay +4. A counter shows how many plots are selected (e.g., "2/4 selected") + +### Exporting + +1. After selecting plots, click the **📄** export button (appears in floating buttons) +2. The system will show export instructions with: + - JSON data for the export request + - Command to run the export script +3. Copy the JSON data and save it as `export_request.json` +4. Run the export command in your terminal + +### Keyboard Shortcuts + +- **Ctrl+E**: Toggle selection mode +- **Escape**: Clear all selections and exit selection mode + +## Export Methods + +The system supports two PDF merging methods: + +### Method 1: pdfjam (Recommended) +```bash +# Install on Ubuntu/Debian +sudo apt-get install texlive-extra-utils + +# Check if available +python export_plots.py --check-deps +``` + +### Method 2: Python libraries +```bash +# Install Python dependencies +pip install PyPDF2 reportlab + +# Check if available +python export_plots.py --check-deps +``` + +## Export Script Usage + +```bash +# Basic usage +python export_plots.py export_request.json + +# Specify output file +python export_plots.py export_request.json --output my_plots.pdf + +# Check available dependencies +python export_plots.py --check-deps +``` + +## JSON Request Format + +```json +{ + "plots": [ + "/path/to/plot1.pdf", + "/path/to/plot2.pdf" + ], + "layout": { + "rows": 1, + "cols": 2 + }, + "output_name": "merged_plots.pdf" +} +``` + +## Layout Options + +- **1 plot**: 1x1 grid +- **2 plots**: 1x2 grid (horizontal) +- **3 plots**: 2x2 grid (one empty slot) +- **4 plots**: 2x2 grid (full) + +## Technical Details + +The export functionality consists of: + +- **Frontend**: JavaScript selection UI and export manager +- **Backend**: Python script for PDF merging +- **CSS**: Styling for selection mode and overlays + +The system is designed to work without requiring a web server, using file-based communication between the browser and Python script. diff --git a/export_api.py b/export_api.py new file mode 100644 index 0000000..c2385f7 --- /dev/null +++ b/export_api.py @@ -0,0 +1,94 @@ +""" +Simple Flask API for PDF Export + +This provides a web API endpoint for the gallery export functionality. +""" + +from flask import Flask, request, jsonify, send_file +from pathlib import Path +import json +import tempfile +import os +from orchestration.pdf_export import PDFExporter, check_dependencies + +app = Flask(__name__) +exporter = PDFExporter() + + +@app.route('/api/export-pdf', methods=['POST']) +def export_pdf(): + """Export selected plots to merged PDF""" + try: + data = request.get_json() + + if not data or 'plots' not in data: + return jsonify({'error': 'No plots specified'}), 400 + + plot_urls = data['plots'] + layout = data.get('layout', {'rows': 2, 'cols': 2}) + + # Convert URLs to file paths + plot_paths = [] + for url in plot_urls: + # Extract path from file:// URL or relative path + if url.startswith('file://'): + path = url[7:] # Remove 'file://' prefix + elif url.startswith('http'): + return jsonify({'error': 'Remote URLs not supported'}), 400 + else: + # Assume relative path from web root + path = url + + # Resolve to absolute path + plot_path = Path(path) + if not plot_path.exists(): + return jsonify({'error': f'Plot not found: {path}'}), 404 + + plot_paths.append(str(plot_path)) + + if not plot_paths: + return jsonify({'error': 'No valid plots found'}), 400 + + # Create merged PDF + pdf_bytes = exporter.merge_plots(plot_paths, layout) + + # Create temporary file to serve + with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp_file: + tmp_file.write(pdf_bytes) + tmp_path = tmp_file.name + + def cleanup_temp_file(): + """Clean up temporary file after sending""" + try: + os.unlink(tmp_path) + except OSError: + pass + + return send_file( + tmp_path, + as_attachment=True, + download_name='merged_plots.pdf', + mimetype='application/pdf' + ) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/api/export-status', methods=['GET']) +def export_status(): + """Check export capabilities""" + deps = check_dependencies() + return jsonify({ + 'available': any(deps.values()), + 'dependencies': deps, + 'methods': { + 'pypdf2': deps['pypdf2'], + 'pdfjam': deps['pdfjam'] + } + }) + + +if __name__ == '__main__': + # For development only + app.run(debug=True, port=5000) diff --git a/export_plots.py b/export_plots.py new file mode 100644 index 0000000..4c4fb0a --- /dev/null +++ b/export_plots.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +Standalone PDF Export Script + +This script processes export requests from the gallery and creates merged PDFs. +Usage: python export_plots.py [request_file.json] +""" + +import sys +import json +import argparse +from pathlib import Path +from orchestration.pdf_export import PDFExporter, check_dependencies + + +def main(): + parser = argparse.ArgumentParser(description='Export gallery plots to merged PDF') + parser.add_argument('request_file', nargs='?', default='export_request.json', + help='JSON file containing export request') + parser.add_argument('--output', '-o', help='Output PDF file path') + parser.add_argument('--check-deps', action='store_true', + help='Check available dependencies') + + args = parser.parse_args() + + if args.check_deps: + deps = check_dependencies() + print("Available dependencies:") + for dep, available in deps.items(): + status = "✓" if available else "✗" + print(f" {status} {dep}") + + if not any(deps.values()): + print("\nNo PDF merging tools available!") + print("Install one of the following:") + print(" - PyPDF2 and reportlab: pip install PyPDF2 reportlab") + print(" - pdfjam: apt-get install texlive-extra-utils (on Ubuntu/Debian)") + return + + request_file = Path(args.request_file) + if not request_file.exists(): + print(f"Error: Request file '{request_file}' not found") + print("Create a JSON file with the following structure:") + print(json.dumps({ + "plots": ["/path/to/plot1.pdf", "/path/to/plot2.pdf"], + "layout": {"rows": 1, "cols": 2}, + "output_name": "merged_plots.pdf" + }, indent=2)) + return 1 + + try: + with open(request_file, 'r') as f: + request_data = json.load(f) + except json.JSONDecodeError as e: + print(f"Error: Invalid JSON in '{request_file}': {e}") + return 1 + + # Validate request data + if 'plots' not in request_data: + print("Error: Missing 'plots' field in request") + return 1 + + plot_paths = request_data['plots'] + layout = request_data.get('layout', {'rows': 2, 'cols': 2}) + output_name = args.output or request_data.get('output_name', 'merged_plots.pdf') + + # Validate plot files exist + missing_files = [] + for plot_path in plot_paths: + if not Path(plot_path).exists(): + missing_files.append(plot_path) + + if missing_files: + print("Error: The following plot files were not found:") + for missing in missing_files: + print(f" - {missing}") + return 1 + + print(f"Exporting {len(plot_paths)} plots to '{output_name}'...") + print(f"Layout: {layout['rows']}x{layout['cols']}") + + # Create exporter and merge plots + exporter = PDFExporter() + + try: + pdf_bytes = exporter.merge_plots(plot_paths, layout, output_name) + + if not args.output and 'output_name' not in request_data: + # Write to file if not already done by exporter + with open(output_name, 'wb') as f: + f.write(pdf_bytes) + + print(f"✓ Successfully created '{output_name}'") + print(f" File size: {len(pdf_bytes) / 1024:.1f} KB") + + # Clean up request file if export was successful + request_file.unlink(missing_ok=True) + + except Exception as e: + print(f"Error during export: {e}") + return 1 + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/generate_gallery.py b/generate_gallery.py index cd640de..e0ff44b 100644 --- a/generate_gallery.py +++ b/generate_gallery.py @@ -183,7 +183,7 @@ def build_gallery(source_dir: Path, web_dir: Path, if relative_path == Path("."): assets_path = "../assets" else: - # Count the number of directory levels to go back + # Count directory levels to go back to gallery, then to public_html depth = len(relative_path.parts) assets_path = "../" * (depth + 1) + "assets" @@ -345,6 +345,10 @@ def main() -> None: "total_size_bytes": current_stats["total_size"] } + # Calculate relative path to assets for single file + # Single files are at depth 1 (gallery_root/source.name/index.html) + assets_path = "../assets" + output_html = source_web_dir / "index.html" with output_html.open("w") as f: f.write(template.render( @@ -355,7 +359,7 @@ def main() -> None: ui=CONFIG.ui, stats=stats, folder_metadata={}, - assets_path="../../assets" + assets_path=assets_path )) print(f"Generated {output_html}") diff --git a/orchestration/pdf_export.py b/orchestration/pdf_export.py new file mode 100644 index 0000000..51df501 --- /dev/null +++ b/orchestration/pdf_export.py @@ -0,0 +1,205 @@ +""" +PDF Export Module for Scientific Gallery Generator + +This module handles exporting and merging multiple plots into a single PDF +using PyPDF2 and reportlab for layout. +""" + +import io +import tempfile +from pathlib import Path +from typing import List, Dict, Any +import subprocess + +try: + from PyPDF2 import PdfReader, PdfWriter + from reportlab.pdfgen import canvas + from reportlab.lib.pagesizes import letter, A4 + from reportlab.lib.utils import ImageReader + HAS_PDF_LIBS = True +except ImportError: + HAS_PDF_LIBS = False + + +class PDFExporter: + """Handles exporting multiple plots to a merged PDF""" + + def __init__(self): + self.page_size = A4 + self.margin = 50 + + def merge_plots(self, plot_paths: List[str], layout: Dict[str, int], + output_path: str = None) -> bytes: + """ + Merge multiple PDF plots into a single PDF with grid layout. + + Args: + plot_paths: List of paths to PDF files + layout: Dictionary with 'rows' and 'cols' keys + output_path: Optional output file path + + Returns: + PDF bytes + """ + if not HAS_PDF_LIBS: + return self._merge_with_pdfjam(plot_paths, layout, output_path) + + return self._merge_with_pypdf(plot_paths, layout, output_path) + + def _merge_with_pypdf(self, plot_paths: List[str], layout: Dict[str, int], + output_path: str = None) -> bytes: + """Merge PDFs using PyPDF2 and reportlab""" + from reportlab.pdfgen import canvas + from reportlab.lib.pagesizes import A4 + + # Create a new PDF with the layout + buffer = io.BytesIO() + c = canvas.Canvas(buffer, pagesize=A4) + page_width, page_height = A4 + + rows = layout['rows'] + cols = layout['cols'] + + # Calculate dimensions for each plot + plot_width = (page_width - 2 * self.margin) / cols + plot_height = (page_height - 2 * self.margin) / rows + + # Place each plot in the grid + for i, plot_path in enumerate(plot_paths): + if i >= rows * cols: + break + + row = i // cols + col = i % cols + + # Calculate position + x = self.margin + col * plot_width + y = page_height - self.margin - (row + 1) * plot_height + + try: + # Read the source PDF + with open(plot_path, 'rb') as f: + reader = PdfReader(f) + if len(reader.pages) > 0: + page = reader.pages[0] + + # Convert PDF page to image and place it + # This is a simplified approach - in practice you'd want + # to properly scale and position the PDF content + self._draw_pdf_placeholder(c, x, y, plot_width, plot_height, + Path(plot_path).stem) + + except Exception as e: + print(f"Error processing {plot_path}: {e}") + self._draw_error_placeholder(c, x, y, plot_width, plot_height) + + c.save() + + if output_path: + with open(output_path, 'wb') as f: + f.write(buffer.getvalue()) + + return buffer.getvalue() + + def _merge_with_pdfjam(self, plot_paths: List[str], layout: Dict[str, int], + output_path: str = None) -> bytes: + """Merge PDFs using pdfjam (requires pdfpages LaTeX package)""" + rows = layout['rows'] + cols = layout['cols'] + + # Create temporary output file + with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp_file: + temp_output = tmp_file.name + + try: + # Build pdfjam command + cmd = [ + 'pdfjam', + '--nup', f'{cols}x{rows}', + '--landscape' if cols > rows else '--no-landscape', + '--frame', 'true', + '--delta', '10pt 10pt', + '--offset', '0pt 0pt', + '--outfile', temp_output + ] + + # Add input files + cmd.extend(plot_paths[:rows * cols]) + + # Run pdfjam + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0: + raise Exception(f"pdfjam failed: {result.stderr}") + + # Read the output file + with open(temp_output, 'rb') as f: + pdf_bytes = f.read() + + if output_path: + with open(output_path, 'wb') as f: + f.write(pdf_bytes) + + return pdf_bytes + + finally: + # Clean up temporary file + Path(temp_output).unlink(missing_ok=True) + + def _draw_pdf_placeholder(self, canvas, x: float, y: float, width: float, + height: float, plot_name: str): + """Draw a placeholder for a PDF plot""" + # Draw border + canvas.setStrokeColorRGB(0.5, 0.5, 0.5) + canvas.setLineWidth(1) + canvas.rect(x, y, width, height) + + # Draw plot name + canvas.setFillColorRGB(0, 0, 0) + canvas.setFont("Helvetica", 10) + text_width = canvas.stringWidth(plot_name, "Helvetica", 10) + text_x = x + (width - text_width) / 2 + text_y = y + height / 2 + canvas.drawString(text_x, text_y, plot_name) + + def _draw_error_placeholder(self, canvas, x: float, y: float, width: float, + height: float): + """Draw an error placeholder""" + # Draw red border + canvas.setStrokeColorRGB(1, 0, 0) + canvas.setLineWidth(2) + canvas.rect(x, y, width, height) + + # Draw error text + canvas.setFillColorRGB(1, 0, 0) + canvas.setFont("Helvetica-Bold", 12) + error_text = "Error loading plot" + text_width = canvas.stringWidth(error_text, "Helvetica-Bold", 12) + text_x = x + (width - text_width) / 2 + text_y = y + height / 2 + canvas.drawString(text_x, text_y, error_text) + + +def check_dependencies() -> Dict[str, bool]: + """Check if required dependencies are available""" + deps = { + 'pypdf2': HAS_PDF_LIBS, + 'pdfjam': False + } + + # Check for pdfjam + try: + result = subprocess.run(['pdfjam', '--version'], + capture_output=True, text=True) + deps['pdfjam'] = result.returncode == 0 + except FileNotFoundError: + pass + + return deps + + +if __name__ == "__main__": + # Test the exporter + exporter = PDFExporter() + deps = check_dependencies() + print("Available dependencies:", deps) diff --git a/templates/gallery.html b/templates/gallery.html index 8ee0e5e..e345b2c 100644 --- a/templates/gallery.html +++ b/templates/gallery.html @@ -104,6 +104,10 @@ Compare plots Ctrl+C +
+ Export plots + Ctrl+E +
Refresh F5 diff --git a/test.ipynb b/test.ipynb index d5d9ae8..10fe056 100644 --- a/test.ipynb +++ b/test.ipynb @@ -227,7 +227,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.9" + "version": "3.12.1" } }, "nbformat": 4,