Version 0.1.0

This commit is contained in:
Kylian Schmidt
2025-07-31 15:27:34 +02:00
parent c2943bbe7b
commit d111c5e8ee
54 changed files with 7288 additions and 100 deletions
+23
View File
@@ -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.")
+147
View File
@@ -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)
+159
View File
@@ -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}")
+205
View File
@@ -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)