344 lines
11 KiB
Python
344 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Figure Export Utilities for Publication-Ready Scientific Figures
|
||
|
||
This module provides utilities to export matplotlib figures in publication-ready
|
||
formats with appropriate settings for various journals.
|
||
"""
|
||
|
||
import matplotlib.pyplot as plt
|
||
from pathlib import Path
|
||
from typing import List, Optional, Union
|
||
|
||
|
||
def save_publication_figure(
|
||
fig: plt.Figure,
|
||
filename: Union[str, Path],
|
||
formats: List[str] = ['pdf', 'png'],
|
||
dpi: int = 300,
|
||
transparent: bool = False,
|
||
bbox_inches: str = 'tight',
|
||
pad_inches: float = 0.1,
|
||
facecolor: str = 'white',
|
||
**kwargs
|
||
) -> List[Path]:
|
||
"""
|
||
Save a matplotlib figure in multiple formats with publication-quality settings.
|
||
|
||
Parameters
|
||
----------
|
||
fig : matplotlib.figure.Figure
|
||
The figure to save
|
||
filename : str or Path
|
||
Base filename (without extension)
|
||
formats : list of str, default ['pdf', 'png']
|
||
List of file formats to save. Options: 'pdf', 'png', 'eps', 'svg', 'tiff'
|
||
dpi : int, default 300
|
||
Resolution for raster formats (png, tiff). 300 DPI is minimum for most journals
|
||
transparent : bool, default False
|
||
If True, save with transparent background
|
||
bbox_inches : str, default 'tight'
|
||
Bounding box specification. 'tight' removes excess whitespace
|
||
pad_inches : float, default 0.1
|
||
Padding around the figure when bbox_inches='tight'
|
||
facecolor : str, default 'white'
|
||
Background color (ignored if transparent=True)
|
||
**kwargs
|
||
Additional keyword arguments passed to fig.savefig()
|
||
|
||
Returns
|
||
-------
|
||
list of Path
|
||
List of paths to saved files
|
||
|
||
Examples
|
||
--------
|
||
>>> fig, ax = plt.subplots()
|
||
>>> ax.plot([1, 2, 3], [1, 4, 9])
|
||
>>> save_publication_figure(fig, 'my_plot', formats=['pdf', 'png'], dpi=600)
|
||
['my_plot.pdf', 'my_plot.png']
|
||
"""
|
||
filename = Path(filename)
|
||
base_name = filename.stem
|
||
output_dir = filename.parent if filename.parent.exists() else Path.cwd()
|
||
|
||
saved_files = []
|
||
|
||
for fmt in formats:
|
||
output_file = output_dir / f"{base_name}.{fmt}"
|
||
|
||
# Set format-specific parameters
|
||
save_kwargs = {
|
||
'dpi': dpi,
|
||
'bbox_inches': bbox_inches,
|
||
'pad_inches': pad_inches,
|
||
'facecolor': facecolor if not transparent else 'none',
|
||
'edgecolor': 'none',
|
||
'transparent': transparent,
|
||
'format': fmt,
|
||
}
|
||
|
||
# Update with user-provided kwargs
|
||
save_kwargs.update(kwargs)
|
||
|
||
# Adjust DPI for vector formats (DPI less relevant)
|
||
if fmt in ['pdf', 'eps', 'svg']:
|
||
save_kwargs['dpi'] = min(dpi, 300) # Lower DPI for embedded rasters in vector
|
||
|
||
try:
|
||
fig.savefig(output_file, **save_kwargs)
|
||
saved_files.append(output_file)
|
||
print(f"✓ Saved: {output_file}")
|
||
except Exception as e:
|
||
print(f"✗ Failed to save {output_file}: {e}")
|
||
|
||
return saved_files
|
||
|
||
|
||
def save_for_journal(
|
||
fig: plt.Figure,
|
||
filename: Union[str, Path],
|
||
journal: str,
|
||
figure_type: str = 'combination'
|
||
) -> List[Path]:
|
||
"""
|
||
Save figure with journal-specific requirements.
|
||
|
||
Parameters
|
||
----------
|
||
fig : matplotlib.figure.Figure
|
||
The figure to save
|
||
filename : str or Path
|
||
Base filename (without extension)
|
||
journal : str
|
||
Journal name. Options: 'nature', 'science', 'cell', 'plos', 'acs', 'ieee'
|
||
figure_type : str, default 'combination'
|
||
Type of figure. Options: 'line_art', 'photo', 'combination'
|
||
|
||
Returns
|
||
-------
|
||
list of Path
|
||
List of paths to saved files
|
||
|
||
Examples
|
||
--------
|
||
>>> fig, ax = plt.subplots()
|
||
>>> ax.plot([1, 2, 3], [1, 4, 9])
|
||
>>> save_for_journal(fig, 'figure1', journal='nature', figure_type='line_art')
|
||
"""
|
||
journal = journal.lower()
|
||
|
||
# Define journal-specific requirements
|
||
journal_specs = {
|
||
'nature': {
|
||
'line_art': {'formats': ['pdf', 'eps'], 'dpi': 1000},
|
||
'photo': {'formats': ['tiff'], 'dpi': 300},
|
||
'combination': {'formats': ['pdf'], 'dpi': 600},
|
||
},
|
||
'science': {
|
||
'line_art': {'formats': ['eps', 'pdf'], 'dpi': 1000},
|
||
'photo': {'formats': ['tiff'], 'dpi': 300},
|
||
'combination': {'formats': ['eps'], 'dpi': 600},
|
||
},
|
||
'cell': {
|
||
'line_art': {'formats': ['pdf', 'eps'], 'dpi': 1000},
|
||
'photo': {'formats': ['tiff'], 'dpi': 300},
|
||
'combination': {'formats': ['pdf'], 'dpi': 600},
|
||
},
|
||
'plos': {
|
||
'line_art': {'formats': ['pdf', 'eps'], 'dpi': 600},
|
||
'photo': {'formats': ['tiff', 'png'], 'dpi': 300},
|
||
'combination': {'formats': ['tiff'], 'dpi': 300},
|
||
},
|
||
'acs': {
|
||
'line_art': {'formats': ['tiff', 'pdf'], 'dpi': 600},
|
||
'photo': {'formats': ['tiff'], 'dpi': 300},
|
||
'combination': {'formats': ['tiff'], 'dpi': 600},
|
||
},
|
||
'ieee': {
|
||
'line_art': {'formats': ['pdf', 'eps'], 'dpi': 600},
|
||
'photo': {'formats': ['tiff'], 'dpi': 300},
|
||
'combination': {'formats': ['pdf'], 'dpi': 300},
|
||
},
|
||
}
|
||
|
||
if journal not in journal_specs:
|
||
available = ', '.join(journal_specs.keys())
|
||
raise ValueError(f"Journal '{journal}' not recognized. Available: {available}")
|
||
|
||
if figure_type not in journal_specs[journal]:
|
||
available = ', '.join(journal_specs[journal].keys())
|
||
raise ValueError(f"Figure type '{figure_type}' not valid. Available: {available}")
|
||
|
||
specs = journal_specs[journal][figure_type]
|
||
|
||
print(f"Saving for {journal.upper()} ({figure_type}):")
|
||
print(f" Formats: {', '.join(specs['formats'])}")
|
||
print(f" DPI: {specs['dpi']}")
|
||
|
||
return save_publication_figure(
|
||
fig=fig,
|
||
filename=filename,
|
||
formats=specs['formats'],
|
||
dpi=specs['dpi']
|
||
)
|
||
|
||
|
||
def check_figure_size(fig: plt.Figure, journal: str = 'nature') -> dict:
|
||
"""
|
||
Check if figure dimensions are appropriate for journal requirements.
|
||
|
||
Parameters
|
||
----------
|
||
fig : matplotlib.figure.Figure
|
||
The figure to check
|
||
journal : str, default 'nature'
|
||
Journal name
|
||
|
||
Returns
|
||
-------
|
||
dict
|
||
Dictionary with figure dimensions and compliance status
|
||
|
||
Examples
|
||
--------
|
||
>>> fig = plt.figure(figsize=(3.5, 3))
|
||
>>> info = check_figure_size(fig, journal='nature')
|
||
>>> print(info)
|
||
"""
|
||
journal = journal.lower()
|
||
|
||
# Get figure dimensions in inches
|
||
width_inches, height_inches = fig.get_size_inches()
|
||
width_mm = width_inches * 25.4
|
||
height_mm = height_inches * 25.4
|
||
|
||
# Journal specifications (widths in mm)
|
||
specs = {
|
||
'nature': {'single': 89, 'double': 183, 'max_height': 247},
|
||
'science': {'single': 55, 'double': 175, 'max_height': 233},
|
||
'cell': {'single': 85, 'double': 178, 'max_height': 230},
|
||
'plos': {'single': 83, 'double': 173, 'max_height': 233},
|
||
'acs': {'single': 82.5, 'double': 178, 'max_height': 247},
|
||
}
|
||
|
||
if journal not in specs:
|
||
journal_spec = specs['nature']
|
||
print(f"Warning: Journal '{journal}' not found, using Nature specifications")
|
||
else:
|
||
journal_spec = specs[journal]
|
||
|
||
# Determine column type
|
||
column_type = None
|
||
width_ok = False
|
||
|
||
tolerance = 5 # mm tolerance
|
||
if abs(width_mm - journal_spec['single']) < tolerance:
|
||
column_type = 'single'
|
||
width_ok = True
|
||
elif abs(width_mm - journal_spec['double']) < tolerance:
|
||
column_type = 'double'
|
||
width_ok = True
|
||
|
||
height_ok = height_mm <= journal_spec['max_height']
|
||
|
||
result = {
|
||
'width_inches': width_inches,
|
||
'height_inches': height_inches,
|
||
'width_mm': width_mm,
|
||
'height_mm': height_mm,
|
||
'journal': journal,
|
||
'column_type': column_type,
|
||
'width_ok': width_ok,
|
||
'height_ok': height_ok,
|
||
'compliant': width_ok and height_ok,
|
||
'recommendations': {
|
||
'single_column_mm': journal_spec['single'],
|
||
'double_column_mm': journal_spec['double'],
|
||
'max_height_mm': journal_spec['max_height'],
|
||
}
|
||
}
|
||
|
||
# Print report
|
||
print(f"\n{'='*60}")
|
||
print(f"Figure Size Check for {journal.upper()}")
|
||
print(f"{'='*60}")
|
||
print(f"Current size: {width_mm:.1f} × {height_mm:.1f} mm")
|
||
print(f" ({width_inches:.2f} × {height_inches:.2f} inches)")
|
||
print(f"\n{journal.upper()} specifications:")
|
||
print(f" Single column: {journal_spec['single']} mm")
|
||
print(f" Double column: {journal_spec['double']} mm")
|
||
print(f" Max height: {journal_spec['max_height']} mm")
|
||
print(f"\nCompliance:")
|
||
print(f" Width: {'✓ OK' if width_ok else '✗ Non-standard'} ({column_type or 'custom'})")
|
||
print(f" Height: {'✓ OK' if height_ok else '✗ Too tall'}")
|
||
print(f" Overall: {'✓ COMPLIANT' if result['compliant'] else '✗ NEEDS ADJUSTMENT'}")
|
||
print(f"{'='*60}\n")
|
||
|
||
return result
|
||
|
||
|
||
def verify_font_embedding(pdf_path: Union[str, Path]) -> bool:
|
||
"""
|
||
Check if fonts are embedded in a PDF file.
|
||
|
||
Note: This requires PyPDF2 or a similar library to be installed.
|
||
|
||
Parameters
|
||
----------
|
||
pdf_path : str or Path
|
||
Path to PDF file
|
||
|
||
Returns
|
||
-------
|
||
bool
|
||
True if fonts are embedded, False otherwise
|
||
"""
|
||
try:
|
||
from PyPDF2 import PdfReader
|
||
except ImportError:
|
||
print("Warning: PyPDF2 not installed. Cannot verify font embedding.")
|
||
print("Install with: pip install PyPDF2")
|
||
return None
|
||
|
||
pdf_path = Path(pdf_path)
|
||
|
||
try:
|
||
reader = PdfReader(pdf_path)
|
||
# This is a simplified check; full verification is complex
|
||
print(f"PDF has {len(reader.pages)} page(s)")
|
||
print("Note: Full font embedding verification requires detailed PDF inspection.")
|
||
return True
|
||
except Exception as e:
|
||
print(f"Error reading PDF: {e}")
|
||
return False
|
||
|
||
|
||
if __name__ == "__main__":
|
||
# Example usage
|
||
import numpy as np
|
||
|
||
# Create example figure
|
||
fig, ax = plt.subplots(figsize=(3.5, 2.5))
|
||
x = np.linspace(0, 10, 100)
|
||
ax.plot(x, np.sin(x), label='sin(x)')
|
||
ax.plot(x, np.cos(x), label='cos(x)')
|
||
ax.set_xlabel('x')
|
||
ax.set_ylabel('y')
|
||
ax.legend()
|
||
ax.spines['top'].set_visible(False)
|
||
ax.spines['right'].set_visible(False)
|
||
|
||
# Check size
|
||
check_figure_size(fig, journal='nature')
|
||
|
||
# Save in multiple formats
|
||
print("\nSaving figure...")
|
||
save_publication_figure(fig, 'example_figure', formats=['pdf', 'png'], dpi=300)
|
||
|
||
# Save with journal-specific requirements
|
||
print("\nSaving for Nature...")
|
||
save_for_journal(fig, 'example_figure_nature', journal='nature', figure_type='line_art')
|
||
|
||
plt.close(fig)
|