from typing import Optional, Dict
import yaml
import os
import shutil
from importlib.resources import files
import matplotlib.font_manager as fm
# --- Module-level paths ---
# Built-in assets
assets_dir = files("pubplotlib").joinpath("assets")
builtin_yaml_filename = assets_dir.joinpath("styles.yaml")
core_styles = ['pubplot.mplstyle', 'aanda.mplstyle', 'apj.mplstyle']
# User config directory
user_dir = os.path.expanduser("~/.pubplotlib")
user_style_dir = os.path.join(user_dir, "style")
user_yaml_filename = os.path.join(user_dir, "styles.yaml")
# Ensure user directories exist
os.makedirs(user_style_dir, exist_ok=True)
[docs]
def check_fonts(font_list=None, verbose=True):
"""
Check if the required fonts are available to Matplotlib.
Prints a summary and returns a dict {font_name: found_bool}.
"""
if font_list is None:
font_list = ["Times", "Arial", "Comic Sans"] # Extend as needed
available_fonts = set(f.name for f in fm.fontManager.ttflist)
missing = [font for font in font_list if font not in available_fonts]
found = {font: (font in available_fonts) for font in font_list}
if verbose:
if not missing:
print("All the fonts are available.")
else:
print(
f"{', '.join(missing)} fonts not available... "
"If you intend to use them, install them and update the matplotlib cache:\n"
" python -c 'import matplotlib.font_manager; matplotlib.font_manager._get_fontconfig_fonts(); matplotlib.font_manager._load_fontmanager(try_read_cache=False)'"
)
return found
[docs]
def build_builtin_styles(overwrite: bool = False) -> Dict[str, Dict[str, object]]:
"""
Build and write the default built-in styles YAML file with standard styles (e.g., journals).
Overwrites the file if overwrite=True.
Args:
overwrite: if True, overwrites the YAML file if it exists.
Returns:
The built-in style dict with dimensions in inches and style paths.
"""
pt = 1 / 72.27 # points to inches conversion factor
styles: Dict[str, Dict[str, object]] = {
"aanda": {
"onecol": 256.0 * pt,
"twocol": 523.5 * pt,
"mplstyle": "aanda.mplstyle",
},
"apj": {
"onecol": 242.0 * pt,
"mplstyle": "apj.mplstyle",
},
}
if builtin_yaml_filename.exists() and not overwrite:
raise FileExistsError(f"{builtin_yaml_filename} already exists. Use overwrite=True to overwrite.")
with open(builtin_yaml_filename, "w", encoding="utf-8") as f:
yaml.safe_dump(styles, f, default_flow_style=False)
return styles
[docs]
def remove_style(name: str):
"""
Remove a user style from the user YAML registry and delete its style file.
Built-in styles cannot be removed.
Args:
name: The name of the style to remove.
"""
if not os.path.exists(user_yaml_filename):
raise FileNotFoundError(f"{user_yaml_filename} does not exist.")
with open(user_yaml_filename, "r", encoding="utf-8") as f:
styles = yaml.safe_load(f) or {}
if name not in styles:
raise ValueError(f"Style '{name}' not found in {user_yaml_filename}.")
style_filename = styles[name].get("mplstyle")
style_path = os.path.join(user_style_dir, style_filename) if style_filename else None
if style_filename and style_path and os.path.exists(style_path):
os.remove(style_path)
del styles[name]
with open(user_yaml_filename, "w", encoding="utf-8") as f:
yaml.safe_dump(styles, f, default_flow_style=False)
[docs]
class Style:
"""
Represents a generic figure formatting style (journal, slide, poster, etc).
Attributes:
name (str): The style's name.
onecol (Optional[float]): Width of a single column (in inches).
twocol (Optional[float]): Width of a double column (in inches).
fullpage (Optional[float]): Width of a full page (in inches).
mplstyle (Optional[str]): Path to the associated .mplstyle file.
"""
def __init__(
self,
name: str,
onecol: Optional[float] = None,
twocol: Optional[float] = None,
fullpage: Optional[float] = None,
mplstyle: Optional[str] = None,
):
self.name = name
self.onecol = onecol
self.twocol = twocol
self.fullpage = fullpage
self.mplstyle = mplstyle
# Always store the style filename (not full path)
if mplstyle is not None:
if os.path.exists(mplstyle):
style_filename = os.path.abspath(mplstyle)
else:
raise FileNotFoundError(f"Style file '{mplstyle}' does not exist.")
self.mplstyle = style_filename
if self.onecol is None and self.twocol is None and self.fullpage is None:
raise ValueError("At least one of 'onecol', 'twocol', or 'fullpage' must be provided.")
[docs]
def register(self, overwrite: bool = False):
"""
Register this Style in the user's styles YAML file.
Copies the mplstyle file into the user's style directory if needed.
Args:
overwrite (bool): If True, overwrite existing entry and style file.
"""
style_filename = os.path.basename(self.mplstyle) if self.mplstyle else None
style_dest = os.path.join(user_style_dir, style_filename) if style_filename else None
if style_filename:
if os.path.exists(style_dest) and not overwrite:
raise FileExistsError(f"Style file '{style_filename}' already exists in the user style directory. Use overwrite=True to replace it.")
else:
shutil.copyfile(self.mplstyle, style_dest)
# Load or create the user YAML
if os.path.exists(user_yaml_filename):
with open(user_yaml_filename, "r", encoding="utf-8") as f:
styles = yaml.safe_load(f) or {}
else:
styles = {}
if self.name in styles and not overwrite:
raise ValueError(
f"Style '{self.name}' already exists in {user_yaml_filename}. Use overwrite=True to replace it or provide a different name."
)
styles[self.name] = {}
if self.onecol is not None:
styles[self.name]["onecol"] = self.onecol
if self.twocol is not None:
styles[self.name]["twocol"] = self.twocol
if self.fullpage is not None:
styles[self.name]["fullpage"] = self.fullpage
styles[self.name]["mplstyle"] = style_filename # Will be None if not provided
with open(user_yaml_filename, "w", encoding="utf-8") as f:
yaml.safe_dump(styles, f, default_flow_style=False)
def __repr__(self):
return (
f"Style(name={self.name!r}, onecol={self.onecol}, "
f"twocol={self.twocol}, fullpage={self.fullpage}, mplstyle={self.mplstyle!r})"
)
# Alias for backward compatibility
[docs]
class Journal(Style):
pass