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,