Version 0.1.0
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import zipfile
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
WEB_FOLDER = Path("plots")
|
||||
BACKUP_FOLDER = Path("backups")
|
||||
|
||||
today = datetime.date.today().strftime("%Y%m%d")
|
||||
backup_name = f"backup-{today}.zip"
|
||||
backup_path = BACKUP_FOLDER / backup_name
|
||||
|
||||
BACKUP_FOLDER.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if backup_path.exists():
|
||||
print(f"Backup already exists: {backup_path}")
|
||||
else:
|
||||
print(f"Creating backup: {backup_path}")
|
||||
with zipfile.ZipFile(backup_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||
for path in WEB_FOLDER.rglob("*"):
|
||||
if path.is_file():
|
||||
arcname = path.relative_to(WEB_FOLDER.parent)
|
||||
zipf.write(path, arcname)
|
||||
print("✅ Backup complete.")
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Scientific Gallery Configuration Management
|
||||
|
||||
This module provides dataclasses and utilities for managing configuration
|
||||
of the scientific gallery system, including paths, gallery settings,
|
||||
UI preferences, and data sources.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class PathConfig:
|
||||
"""Configuration for system paths and directories."""
|
||||
work_dir: str
|
||||
web_folder: str
|
||||
cgi_script: str
|
||||
config_path: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class GalleryConfig:
|
||||
"""Configuration for gallery generation and display settings."""
|
||||
plot_root: str
|
||||
png_dpi: int
|
||||
backup_folder: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class UIConfig:
|
||||
"""Configuration for user interface behavior and preferences."""
|
||||
max_recent_plots: int
|
||||
search_debounce_ms: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetadataConfig:
|
||||
"""Configuration for metadata handling."""
|
||||
cache_enabled: bool = True
|
||||
inherit_from_parent: bool = True
|
||||
supported_formats: list[str] = field(
|
||||
default_factory=lambda: ['.yaml', '.yml', '.json']
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GalleryItem:
|
||||
"""Represents a single data source for the gallery."""
|
||||
name: str
|
||||
path: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""
|
||||
Main configuration class that aggregates all gallery settings.
|
||||
|
||||
Provides backward compatibility properties and methods for loading
|
||||
configuration from YAML files.
|
||||
"""
|
||||
paths: PathConfig
|
||||
gallery: GalleryConfig
|
||||
ui: UIConfig
|
||||
metadata: MetadataConfig
|
||||
sources: list[GalleryItem] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def web_folder(self):
|
||||
"""Backward compatibility property for web folder path."""
|
||||
return self.paths.web_folder
|
||||
|
||||
@property
|
||||
def png_dpi(self):
|
||||
"""Backward compatibility property for PNG conversion DPI."""
|
||||
return self.gallery.png_dpi
|
||||
|
||||
@property
|
||||
def plot_root(self):
|
||||
"""Backward compatibility property for plot root directory."""
|
||||
return self.gallery.plot_root
|
||||
|
||||
@property
|
||||
def backup_folder(self):
|
||||
"""Backward compatibility property for backup folder path."""
|
||||
return self.gallery.backup_folder
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, yaml_file: str) -> "Config":
|
||||
"""
|
||||
Load configuration from a YAML file.
|
||||
|
||||
Args:
|
||||
yaml_file: Path to the YAML configuration file
|
||||
|
||||
Returns:
|
||||
Config instance with loaded settings
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the YAML file doesn't exist
|
||||
yaml.YAMLError: If the YAML file is malformed
|
||||
"""
|
||||
with open(yaml_file, "r") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
paths_data = data.get('paths', {})
|
||||
gallery_data = data.get('gallery', {})
|
||||
ui_data = data.get('ui', {})
|
||||
metadata_data = data.get('metadata', {})
|
||||
sources_data = data.get('sources', [])
|
||||
|
||||
paths = PathConfig(**paths_data)
|
||||
gallery = GalleryConfig(**gallery_data)
|
||||
ui = UIConfig(**ui_data)
|
||||
metadata = MetadataConfig(**metadata_data)
|
||||
|
||||
sources = [
|
||||
GalleryItem(name=source["name"], path=Path(source["path"]))
|
||||
for source in sources_data
|
||||
]
|
||||
|
||||
return cls(
|
||||
paths=paths,
|
||||
gallery=gallery,
|
||||
ui=ui,
|
||||
metadata=metadata,
|
||||
sources=sources
|
||||
)
|
||||
|
||||
def to_yaml(self, yaml_file: str) -> None:
|
||||
"""
|
||||
Save the current configuration to a YAML file.
|
||||
|
||||
Args:
|
||||
yaml_file: Path where to save the YAML configuration
|
||||
|
||||
Raises:
|
||||
IOError: If unable to write to the specified file
|
||||
"""
|
||||
with open(yaml_file, "w") as f:
|
||||
yaml.dump(asdict(self), f, default_flow_style=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
config = Config.from_yaml("config.yaml")
|
||||
print("Loaded config successfully:", config)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Metadata Management for Scientific Gallery Generator
|
||||
|
||||
This module handles loading, parsing, and caching of metadata for plots
|
||||
and folders in the gallery system. Supports YAML and JSON formats with
|
||||
hierarchical inheritance.
|
||||
|
||||
Features:
|
||||
- Load metadata from YAML/JSON files
|
||||
- Hierarchical metadata inheritance from parent folders
|
||||
- Plot-specific metadata overrides
|
||||
- Metadata caching for performance
|
||||
"""
|
||||
|
||||
import json
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
def load_metadata_file(metadata_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Load metadata from a YAML or JSON file.
|
||||
|
||||
Args:
|
||||
metadata_path: Path to the metadata file
|
||||
|
||||
Returns:
|
||||
Dictionary containing the metadata, empty dict if file doesn't exist
|
||||
or can't be parsed
|
||||
"""
|
||||
if not metadata_path.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with metadata_path.open('r', encoding='utf-8') as f:
|
||||
suffix_lower = metadata_path.suffix.lower()
|
||||
if suffix_lower == '.yaml' or suffix_lower == '.yml':
|
||||
return yaml.safe_load(f) or {}
|
||||
elif metadata_path.suffix.lower() == '.json':
|
||||
return json.load(f) or {}
|
||||
else:
|
||||
print(f"Warning: Unknown metadata file format: "
|
||||
f"{metadata_path}")
|
||||
return {}
|
||||
except (yaml.YAMLError, json.JSONDecodeError, IOError) as e:
|
||||
print(f"Warning: Could not parse metadata file {metadata_path}: {e}")
|
||||
raise e
|
||||
|
||||
|
||||
def load_folder_metadata(folder_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Load folder-level metadata from metadata.yaml, metadata.yml, or metadata.json.
|
||||
|
||||
Args:
|
||||
folder_path: Path to the folder to check for metadata
|
||||
|
||||
Returns:
|
||||
Dictionary containing the folder metadata
|
||||
"""
|
||||
# Try YAML first, then JSON for backwards compatibility
|
||||
for filename in ['metadata.yaml', 'metadata.yml', 'metadata.json']:
|
||||
metadata_path = folder_path / filename
|
||||
if metadata_path.exists():
|
||||
return load_metadata_file(metadata_path)
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def get_metadata_file_path(folder_path: Path) -> str:
|
||||
"""
|
||||
Get the metadata file path for a folder. Returns existing file if found,
|
||||
otherwise suggests metadata.yaml (preferred format).
|
||||
|
||||
Args:
|
||||
folder_path: Path to the folder to check for metadata
|
||||
|
||||
Returns:
|
||||
String path to the metadata file (existing or suggested)
|
||||
"""
|
||||
# Preferred order: YAML first, then JSON
|
||||
preferred_files = ['metadata.yaml', 'metadata.yml', 'metadata.json']
|
||||
|
||||
for filename in preferred_files:
|
||||
metadata_path = folder_path / filename
|
||||
if metadata_path.exists():
|
||||
return str(metadata_path)
|
||||
|
||||
# If no file exists, suggest metadata.yaml (preferred format)
|
||||
return str(folder_path / 'metadata.yaml')
|
||||
|
||||
|
||||
def merge_metadata(
|
||||
parent_metadata: Dict[str, Any],
|
||||
child_metadata: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Merge parent and child metadata, with child values overriding parent
|
||||
values.
|
||||
|
||||
Args:
|
||||
parent_metadata: Metadata from parent folder
|
||||
child_metadata: Metadata from current folder
|
||||
|
||||
Returns:
|
||||
Merged metadata dictionary
|
||||
"""
|
||||
merged = parent_metadata.copy()
|
||||
merged.update(child_metadata)
|
||||
return merged
|
||||
|
||||
|
||||
def resolve_metadata_for_plot(
|
||||
plot_path: Path,
|
||||
inherited_metadata: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Resolve metadata for a specific plot by merging inherited metadata
|
||||
with plot-specific metadata.
|
||||
|
||||
Args:
|
||||
plot_path: Path to the plot file (PDF)
|
||||
inherited_metadata: Metadata inherited from folder hierarchy
|
||||
|
||||
Returns:
|
||||
Final merged metadata for the plot
|
||||
"""
|
||||
plot_stem = plot_path.stem
|
||||
plot_dir = plot_path.parent
|
||||
|
||||
# Check for plot-specific metadata files
|
||||
for suffix in ['.yaml', '.yml', '.json']:
|
||||
plot_metadata_path = plot_dir / f"{plot_stem}{suffix}"
|
||||
if plot_metadata_path.exists():
|
||||
plot_metadata = load_metadata_file(plot_metadata_path)
|
||||
return merge_metadata(inherited_metadata, plot_metadata)
|
||||
|
||||
# No plot-specific metadata found, return inherited metadata
|
||||
return inherited_metadata.copy()
|
||||
|
||||
|
||||
def save_metadata_cache(
|
||||
web_dir: Path,
|
||||
plot_metadata_cache: Dict[str, Dict[str, Any]]
|
||||
) -> None:
|
||||
"""
|
||||
Save plot metadata cache to meta_cache.json in the web directory.
|
||||
|
||||
Args:
|
||||
web_dir: Web directory where the cache file should be saved
|
||||
plot_metadata_cache: Dictionary mapping plot names to their metadata
|
||||
"""
|
||||
cache_path = web_dir / "meta_cache.json"
|
||||
try:
|
||||
with cache_path.open('w', encoding='utf-8') as f:
|
||||
json.dump(plot_metadata_cache, f, indent=2, ensure_ascii=False)
|
||||
print(f"Saved metadata cache: {cache_path}")
|
||||
except IOError as e:
|
||||
print(f"Warning: Could not save metadata cache {cache_path}: {e}")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user