#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Save figure as bundle (ZIP format)."""
import hashlib
import json
import tempfile
import warnings
import zipfile
from pathlib import Path
from typing import Optional, Union
from ._extract import (
extract_data_from_record,
extract_spec_from_record,
extract_style_from_record,
)
from ._paths import DATA_FILENAME, EXPORTS_DIR, SPEC_FILENAME, STYLE_FILENAME
[docs]
def save_bundle(
fig,
path: Union[str, Path],
dpi: Optional[int] = None,
image_formats: Optional[list] = None,
save_hitmap: bool = True,
verbose: bool = True,
) -> Path:
"""Save figure as a layered bundle (ZIP format).
Bundle structure inside ZIP:
spec.json # WHAT (semantic specification)
style.json # HOW (appearance settings)
data.csv # DATA (immutable source data)
exports/
figure.png
figure_hitmap.png
Parameters
----------
fig : RecordingFigure
The figure to save.
path : str or Path
Output path (.zip will be added if not present).
dpi : int, optional
DPI for exports (default from style or 300).
image_formats : list, optional
Image formats to export (default: ['png']).
save_hitmap : bool
Whether to save hitmap for GUI editing (default: True).
verbose : bool
Whether to print status (default: True).
Returns
-------
Path
Path to saved ZIP bundle.
"""
from .._wrappers import RecordingFigure
if not isinstance(fig, RecordingFigure):
raise TypeError(
"Expected RecordingFigure. Use fr.subplots() to create "
"a recording-enabled figure."
)
# Ensure .zip extension
path = Path(path)
if path.suffix.lower() != ".zip":
path = path.with_suffix(".zip")
# Get DPI
if dpi is None:
from .._api._save import get_save_dpi
dpi = get_save_dpi()
# Create temporary directory for bundle contents
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = Path(tmpdir)
exports_dir = tmpdir / EXPORTS_DIR
exports_dir.mkdir()
# Extract and save spec
spec = extract_spec_from_record(fig.record)
# Extract and save data
df = extract_data_from_record(fig.record)
if not df.empty:
# Add data hash for integrity tracking
data_hash = hashlib.sha256(df.to_csv().encode()).hexdigest()[:16]
spec["data_hash"] = data_hash
df.to_csv(tmpdir / DATA_FILENAME, index=False)
# Save spec (after adding data_hash)
with open(tmpdir / SPEC_FILENAME, "w") as f:
json.dump(spec, f, indent=2, default=str)
# Extract and save style
style = extract_style_from_record(fig.record)
with open(tmpdir / STYLE_FILENAME, "w") as f:
json.dump(style, f, indent=2, default=str)
# Export images
image_formats = image_formats or ["png"]
for fmt in image_formats:
export_path = exports_dir / f"figure.{fmt}"
fig.fig.savefig(export_path, dpi=dpi)
# Save hitmap
if save_hitmap:
try:
from .._editor._hitmap import generate_hitmap
hitmap_img, _ = generate_hitmap(fig, dpi=min(dpi, 150))
hitmap_path = exports_dir / "figure_hitmap.png"
hitmap_img.save(hitmap_path)
except Exception as e:
if verbose:
warnings.warn(f"Hitmap generation failed: {e}")
# Create ZIP file with root directory matching zip filename
path.parent.mkdir(parents=True, exist_ok=True)
root_dir = path.stem # e.g., "figure" from "figure.zip"
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
for file in tmpdir.rglob("*"):
if file.is_file():
arcname = Path(root_dir) / file.relative_to(tmpdir)
zf.write(file, arcname)
if verbose:
print(f"Saved bundle: {path}")
print(f" (extracts to: {path.stem}/)")
with zipfile.ZipFile(path, "r") as zf:
for info in zf.infolist():
if not info.is_dir():
print(f" {info.filename}: {info.file_size} bytes")
return path