Add export to latex button (i think)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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 = `
|
||||
<div class="selection-checkbox">
|
||||
<span class="checkbox-icon">☐</span>
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="export-instructions">
|
||||
<h3>Export Instructions</h3>
|
||||
<p>To export your selected plots, save the following data to a file called <code>export_request.json</code> and run the export script:</p>
|
||||
<div class="export-data">
|
||||
<textarea readonly>${requestData}</textarea>
|
||||
</div>
|
||||
<div class="export-commands">
|
||||
<p><strong>Command to run:</strong></p>
|
||||
<code>cd /work/kschmidt/web && python export_plots.py export_request.json</code>
|
||||
</div>
|
||||
<div class="export-actions">
|
||||
<button onclick="this.parentElement.parentElement.parentElement.remove()">Close</button>
|
||||
<button onclick="navigator.clipboard.writeText('${requestData.replace(/'/g, "\\'")}')">Copy JSON</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
+107
@@ -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())
|
||||
+6
-2
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
@@ -104,6 +104,10 @@
|
||||
<span>Compare plots</span>
|
||||
<span class="shortcut-key">Ctrl+C</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span>Export plots</span>
|
||||
<span class="shortcut-key">Ctrl+E</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span>Refresh</span>
|
||||
<span class="shortcut-key">F5</span>
|
||||
|
||||
+1
-1
@@ -227,7 +227,7 @@
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.12.9"
|
||||
"version": "3.12.1"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
|
||||
Reference in New Issue
Block a user