Source code for figrecipe._bundle._figz

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Multi-panel figure bundle (.fig.zip) - container for Pltz panels."""

from __future__ import annotations

import json
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union

if TYPE_CHECKING:
    from ._pltz import Pltz

__all__ = ["Figz"]

_MANIFEST = {"bundle_type": "FIGZ", "version": "1.0", "created_by": "figrecipe"}


[docs] class Figz: """Multi-panel figure bundle (.fig.zip). Manages a ZIP file containing: manifest.json - bundle type declaration spec.json - figure spec with panel list style.json - figure dimensions and theme panels/ - .plt.zip panel bundles Example ------- >>> figz = Figz.create("Figure1.fig.zip", "Figure1") >>> figz.add_panel("A", pltz_path) >>> figz.add_panel("B", pltz_bytes) """
[docs] def __init__(self, path: Union[str, Path]): self.path = Path(path) self._load()
def _load(self) -> None: if not self.path.exists(): raise FileNotFoundError(f"Figz bundle not found: {self.path}") with zipfile.ZipFile(self.path, "r") as zf: namelist = zf.namelist() self.spec = ( json.loads(zf.read("spec.json")) if "spec.json" in namelist else {} ) self.style = ( json.loads(zf.read("style.json")) if "style.json" in namelist else {} ) self.panels: Dict[str, Dict] = { p["label"]: p for p in self.spec.get("panels", []) }
[docs] @classmethod def create( cls, path: Union[str, Path], name: str, size_mm: Optional[Dict] = None, ) -> "Figz": """Create a new empty .fig.zip bundle. Parameters ---------- path : str or Path Output path (should end in .fig.zip). name : str Figure name / ID. size_mm : dict, optional Canvas size e.g. {"width_mm": 170, "height_mm": 120}. Returns ------- Figz Loaded Figz instance. """ path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) spec = { "figure": {"id": name, "title": name, "caption": ""}, "panels": [], } style = { "size": size_mm or {"width_mm": 170, "height_mm": 120}, "background": "#ffffff", "theme": {"mode": "light"}, } with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf: zf.writestr("manifest.json", json.dumps(_MANIFEST, indent=2)) zf.writestr("spec.json", json.dumps(spec, indent=2)) zf.writestr("style.json", json.dumps(style, indent=2)) return cls(path)
[docs] def save(self) -> None: """Save spec/style changes back to bundle.""" with zipfile.ZipFile(self.path, "r") as zf: entries = {name: zf.read(name) for name in zf.namelist()} entries["spec.json"] = json.dumps(self.spec, indent=2, default=str).encode() entries["style.json"] = json.dumps(self.style, indent=2, default=str).encode() tmp = Path(tempfile.mktemp(suffix=self.path.suffix)) try: with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zf: for name, data in entries.items(): zf.writestr(name, data) shutil.move(str(tmp), str(self.path)) except Exception: if tmp.exists(): tmp.unlink() raise
[docs] def add_panel( self, label: str, pltz_source: Union[bytes, str, Path], position: Optional[Dict] = None, size: Optional[Dict] = None, ) -> None: """Add a .plt.zip panel to this figure. Parameters ---------- label : str Panel label (e.g., "A", "B"). pltz_source : bytes, str, or Path Panel bytes or path to a .plt.zip file. position : dict, optional Panel position e.g. {"x_mm": 5, "y_mm": 5}. size : dict, optional Panel size e.g. {"width_mm": 80, "height_mm": 68}. """ if isinstance(pltz_source, (str, Path)): pltz_bytes = Path(pltz_source).read_bytes() else: pltz_bytes = pltz_source # Read all current entries with zipfile.ZipFile(self.path, "r") as zf: entries = {name: zf.read(name) for name in zf.namelist()} # Store panel bytes entries[f"panels/{label}.plt.zip"] = pltz_bytes # Update spec.panels if label not in self.panels: panel_info: Dict[str, Any] = {"label": label} if position: panel_info["position"] = position if size: panel_info["size"] = size self.panels[label] = panel_info self.spec["panels"] = list(self.panels.values()) else: # Update position/size if provided if position: self.panels[label]["position"] = position if size: self.panels[label]["size"] = size self.spec["panels"] = list(self.panels.values()) entries["spec.json"] = json.dumps(self.spec, indent=2, default=str).encode() # Rebuild ZIP atomically tmp = Path(tempfile.mktemp(suffix=self.path.suffix)) try: with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zf: for name, data in entries.items(): zf.writestr(name, data) shutil.move(str(tmp), str(self.path)) except Exception: if tmp.exists(): tmp.unlink() raise
[docs] def add_panel_from_png( self, label: str, png_bytes: bytes, plot_type: str = "image", position: Optional[Dict] = None, size: Optional[Dict] = None, hitmap_bytes: Optional[bytes] = None, hitmap_color_map: Optional[Dict] = None, data_csv: Optional[str] = None, ) -> None: """Create a .plt.zip bundle from PNG bytes and embed it as a panel. Convenience wrapper around :meth:`add_panel` for the common case of adding a pre-rendered PNG image as a panel. Parameters ---------- label : str Panel label (e.g., "A", "B"). png_bytes : bytes PNG image data. plot_type : str, optional Plot type label stored in the panel's spec.json (default: "image"). position : dict, optional Panel position e.g. {"x_mm": 5, "y_mm": 5}. size : dict, optional Panel size e.g. {"width_mm": 80, "height_mm": 68}. """ from ._pltz import Pltz tmp = Path(tempfile.mktemp(suffix=".plt.zip")) try: Pltz.from_png( png_bytes, tmp, plot_type=plot_type, hitmap_bytes=hitmap_bytes, hitmap_color_map=hitmap_color_map, data_csv=data_csv, ) self.add_panel(label, tmp.read_bytes(), position, size) finally: if tmp.exists(): tmp.unlink()
[docs] def remove_panel(self, label: str) -> None: """Remove a panel (rebuilds ZIP). Parameters ---------- label : str Panel label to remove. """ panel_key = f"panels/{label}.plt.zip" with zipfile.ZipFile(self.path, "r") as zf: entries = { name: zf.read(name) for name in zf.namelist() if name != panel_key } self.panels.pop(label, None) self.spec["panels"] = list(self.panels.values()) entries["spec.json"] = json.dumps(self.spec, indent=2, default=str).encode() tmp = Path(tempfile.mktemp(suffix=self.path.suffix)) try: with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zf: for name, data in entries.items(): zf.writestr(name, data) shutil.move(str(tmp), str(self.path)) except Exception: if tmp.exists(): tmp.unlink() raise
[docs] def get_panel(self, label: str) -> "Pltz": """Extract panel as Pltz instance (via temp file). Note: The caller is responsible for cleaning up the temp file (accessible via the returned Pltz instance's .path attribute). Parameters ---------- label : str Panel label. Returns ------- Pltz Loaded Pltz instance for the panel. """ from ._pltz import Pltz data = self.get_panel_pltz(label) if data is None: raise KeyError(f"Panel '{label}' not found in {self.path}") tmp = Path(tempfile.mktemp(suffix=".plt.zip")) tmp.write_bytes(data) return Pltz(tmp)
[docs] def get_panel_pltz(self, panel_id: str) -> Optional[bytes]: """Get panel as raw bytes. Parameters ---------- panel_id : str Panel label. Returns ------- bytes or None Raw .plt.zip bytes, or None if panel not found. """ panel_key = f"panels/{panel_id}.plt.zip" try: with zipfile.ZipFile(self.path, "r") as zf: if panel_key in zf.namelist(): return zf.read(panel_key) except Exception: pass return None
[docs] def list_panel_ids(self) -> List[str]: """List panel labels in order. Returns ------- list of str Panel labels. """ return list(self.panels.keys())
[docs] def get_panel_data(self, panel_id: str): """Get panel's CSV data as DataFrame. Parameters ---------- panel_id : str Panel label. Returns ------- DataFrame or None """ import tempfile as _tempfile pltz_bytes = self.get_panel_pltz(panel_id) if pltz_bytes is None: return None from ._pltz import Pltz tmp = Path(_tempfile.mktemp(suffix=".plt.zip")) try: tmp.write_bytes(pltz_bytes) pltz = Pltz(tmp) return pltz.data finally: if tmp.exists(): tmp.unlink()
[docs] def render_preview(self) -> Optional[bytes]: """Return preview of first panel as PNG bytes. Returns ------- bytes or None """ panel_ids = self.list_panel_ids() if not panel_ids: return None from ._pltz import Pltz for pid in panel_ids: pltz_bytes = self.get_panel_pltz(pid) if pltz_bytes: tmp = Path(tempfile.mktemp(suffix=".plt.zip")) try: tmp.write_bytes(pltz_bytes) pltz = Pltz(tmp) preview = pltz.get_preview() or pltz.render_preview() if preview: return preview finally: if tmp.exists(): tmp.unlink() return None
# EOF