figrecipe 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,412 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Style application utilities for figrecipe.
4
+
5
+ Applies mm-based styling to matplotlib axes for publication-quality figures.
6
+ """
7
+
8
+ __all__ = ["apply_style_mm", "apply_theme_colors", "check_font", "list_available_fonts"]
9
+
10
+ import warnings
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from matplotlib.axes import Axes
14
+
15
+ from .._utils._units import mm_to_pt
16
+
17
+
18
+ def list_available_fonts() -> List[str]:
19
+ """List all available font families.
20
+
21
+ Returns
22
+ -------
23
+ list of str
24
+ Sorted list of available font family names.
25
+
26
+ Examples
27
+ --------
28
+ >>> fonts = ps.list_available_fonts()
29
+ >>> print(fonts[:5])
30
+ ['Arial', 'Courier New', 'DejaVu Sans', ...]
31
+ """
32
+ import matplotlib.font_manager as fm
33
+ fonts = set()
34
+ for font in fm.fontManager.ttflist:
35
+ fonts.add(font.name)
36
+ return sorted(fonts)
37
+
38
+
39
+ def check_font(font_family: str, fallback: str = "DejaVu Sans") -> str:
40
+ """Check if font is available, with fallback and helpful error message.
41
+
42
+ Parameters
43
+ ----------
44
+ font_family : str
45
+ Requested font family name.
46
+ fallback : str
47
+ Fallback font if requested font is not available.
48
+
49
+ Returns
50
+ -------
51
+ str
52
+ The font to use (original if available, fallback otherwise).
53
+
54
+ Examples
55
+ --------
56
+ >>> font = check_font("Arial") # Returns "Arial" if available
57
+ >>> font = check_font("NonExistentFont") # Returns fallback with warning
58
+ """
59
+ import matplotlib.font_manager as fm
60
+
61
+ available = list_available_fonts()
62
+
63
+ if font_family in available:
64
+ return font_family
65
+
66
+ # Font not found - show helpful message
67
+ similar = [f for f in available if font_family.lower() in f.lower()]
68
+
69
+ msg = f"Font '{font_family}' not found.\n"
70
+ if similar:
71
+ msg += f" Similar fonts available: {similar[:5]}\n"
72
+ msg += f" Using fallback: '{fallback}'\n"
73
+ msg += f" To see all available fonts: ps.list_available_fonts()\n"
74
+ msg += f" To install Arial on Linux: sudo apt install ttf-mscorefonts-installer"
75
+
76
+ warnings.warn(msg, UserWarning)
77
+
78
+ return fallback if fallback in available else "DejaVu Sans"
79
+
80
+
81
+ # Default theme color palettes (Monaco/VS Code style for dark)
82
+ THEME_COLORS = {
83
+ "dark": {
84
+ "figure_bg": "#1e1e1e", # VS Code main background
85
+ "axes_bg": "#252526", # VS Code panel background
86
+ "legend_bg": "#252526", # Same as axes
87
+ "text": "#d4d4d4", # VS Code default text
88
+ "spine": "#3c3c3c", # Subtle border color
89
+ "tick": "#d4d4d4", # Match text
90
+ "grid": "#3a3a3a", # Subtle grid
91
+ },
92
+ "light": {
93
+ "figure_bg": "none", # Transparent
94
+ "axes_bg": "none", # Transparent
95
+ "legend_bg": "none", # Transparent
96
+ "text": "black",
97
+ "spine": "black",
98
+ "tick": "black",
99
+ "grid": "#cccccc",
100
+ },
101
+ }
102
+
103
+
104
+ def apply_theme_colors(
105
+ ax: Axes,
106
+ theme: str = "light",
107
+ custom_colors: Optional[Dict[str, str]] = None,
108
+ ) -> None:
109
+ """Apply theme colors to axes for dark/light mode support.
110
+
111
+ Parameters
112
+ ----------
113
+ ax : matplotlib.axes.Axes
114
+ Target axes to apply theme to
115
+ theme : str
116
+ Color theme: "light" or "dark" (default: "light")
117
+ custom_colors : dict, optional
118
+ Custom color overrides. Keys: figure_bg, axes_bg, legend_bg, text, spine, tick, grid
119
+
120
+ Examples
121
+ --------
122
+ >>> fig, ax = plt.subplots()
123
+ >>> apply_theme_colors(ax, theme="dark") # Eye-friendly dark mode
124
+ """
125
+ # Get base theme colors
126
+ colors = THEME_COLORS.get(theme, THEME_COLORS["light"]).copy()
127
+
128
+ # Apply custom overrides
129
+ if custom_colors:
130
+ # Handle legacy key name (background -> figure_bg)
131
+ if "background" in custom_colors and "figure_bg" not in custom_colors:
132
+ custom_colors["figure_bg"] = custom_colors.pop("background")
133
+ colors.update(custom_colors)
134
+
135
+ # Helper to check for transparent/none
136
+ def is_transparent(color):
137
+ if color is None:
138
+ return False
139
+ return str(color).lower() in ("none", "transparent")
140
+
141
+ # Apply axes background (handle "none"/"transparent" for transparency)
142
+ axes_bg = colors.get("axes_bg", "none")
143
+ if is_transparent(axes_bg):
144
+ ax.set_facecolor("none")
145
+ ax.patch.set_alpha(0)
146
+ else:
147
+ ax.set_facecolor(axes_bg)
148
+
149
+ # Apply figure background if accessible
150
+ fig = ax.get_figure()
151
+ if fig is not None:
152
+ fig_bg = colors.get("figure_bg", "none")
153
+ if is_transparent(fig_bg):
154
+ fig.patch.set_facecolor("none")
155
+ fig.patch.set_alpha(0)
156
+ else:
157
+ fig.patch.set_facecolor(fig_bg)
158
+
159
+ # Apply text colors (labels, titles)
160
+ ax.xaxis.label.set_color(colors["text"])
161
+ ax.yaxis.label.set_color(colors["text"])
162
+ ax.title.set_color(colors["text"])
163
+
164
+ # Apply spine colors
165
+ for spine in ax.spines.values():
166
+ spine.set_color(colors["spine"])
167
+
168
+ # Apply tick colors (both marks and labels)
169
+ ax.tick_params(colors=colors["tick"], which="both")
170
+ for label in ax.get_xticklabels() + ax.get_yticklabels():
171
+ label.set_color(colors["tick"])
172
+
173
+ # Apply legend colors if legend exists
174
+ legend = ax.get_legend()
175
+ if legend is not None:
176
+ for text in legend.get_texts():
177
+ text.set_color(colors["text"])
178
+ title = legend.get_title()
179
+ if title:
180
+ title.set_color(colors["text"])
181
+ frame = legend.get_frame()
182
+ if frame:
183
+ legend_bg = colors.get("legend_bg", colors.get("axes_bg", "none"))
184
+ if is_transparent(legend_bg):
185
+ frame.set_facecolor("none")
186
+ frame.set_alpha(0)
187
+ else:
188
+ frame.set_facecolor(legend_bg)
189
+ frame.set_edgecolor(colors["spine"])
190
+
191
+
192
+ def apply_style_mm(ax: Axes, style: Dict[str, Any]) -> float:
193
+ """Apply publication-quality style using millimeter-based settings.
194
+
195
+ This function applies styling to matplotlib axes using millimeter and point
196
+ measurements for precise control over visual elements.
197
+
198
+ Parameters
199
+ ----------
200
+ ax : matplotlib.axes.Axes
201
+ Target axes to apply styling to
202
+ style : dict
203
+ Dictionary containing styling parameters. Supported keys:
204
+ - 'axes_thickness_mm' (float): Spine line width in mm (default: 0.2)
205
+ - 'trace_thickness_mm' (float): Plot line width in mm (default: 0.3)
206
+ - 'tick_length_mm' (float): Tick mark length in mm (default: 1.0)
207
+ - 'tick_thickness_mm' (float): Tick mark width in mm (default: 0.2)
208
+ - 'marker_size_mm' (float): Default marker size in mm (default: 1.0)
209
+ - 'axis_font_size_pt' (float): Axis label font size in points (default: 8)
210
+ - 'tick_font_size_pt' (float): Tick label font size in points (default: 7)
211
+ - 'title_font_size_pt' (float): Title font size in points (default: 9)
212
+ - 'legend_font_size_pt' (float): Legend font size in points (default: 7)
213
+ - 'label_pad_pt' (float): Axis label padding in points (default: 2.0)
214
+ - 'tick_pad_pt' (float): Tick label padding in points (default: 2.0)
215
+ - 'title_pad_pt' (float): Title padding in points (default: 4.0)
216
+ - 'font_family' (str): Font family (default: "Arial")
217
+ - 'n_ticks' (int): Number of ticks on each axis (default: 5)
218
+ - 'theme' (str): Color theme "light" or "dark" (default: "light")
219
+ - 'theme_colors' (dict): Custom theme color overrides
220
+ - 'hide_top_spine' (bool): Hide top spine (default: True)
221
+ - 'hide_right_spine' (bool): Hide right spine (default: True)
222
+ - 'grid' (bool): Show grid (default: False)
223
+
224
+ Returns
225
+ -------
226
+ float
227
+ Trace line width in points, to be used with ax.plot(..., lw=trace_lw)
228
+
229
+ Examples
230
+ --------
231
+ >>> fig, ax = plt.subplots()
232
+ >>> style = {
233
+ ... 'axes_thickness_mm': 0.2,
234
+ ... 'trace_thickness_mm': 0.3,
235
+ ... 'tick_length_mm': 1.0,
236
+ ... 'axis_font_size_pt': 8,
237
+ ... 'theme': 'light',
238
+ ... }
239
+ >>> trace_lw = apply_style_mm(ax, style)
240
+ >>> ax.plot(x, y, lw=trace_lw)
241
+ """
242
+ # Apply theme colors (dark/light mode)
243
+ theme = style.get("theme", "light")
244
+ theme_colors = style.get("theme_colors", None)
245
+ apply_theme_colors(ax, theme, theme_colors)
246
+
247
+ # Convert spine thickness from mm to points
248
+ axes_lw_pt = mm_to_pt(style.get("axes_thickness_mm", 0.2))
249
+ for spine in ax.spines.values():
250
+ spine.set_linewidth(axes_lw_pt)
251
+
252
+ # Hide spines if requested
253
+ if style.get("hide_top_spine", True):
254
+ ax.spines["top"].set_visible(False)
255
+ if style.get("hide_right_spine", True):
256
+ ax.spines["right"].set_visible(False)
257
+
258
+ # Convert trace thickness from mm to points
259
+ trace_lw_pt = mm_to_pt(style.get("trace_thickness_mm", 0.3))
260
+
261
+ # Convert marker size from mm to points
262
+ marker_size_mm = style.get("marker_size_mm")
263
+ if marker_size_mm is not None:
264
+ import matplotlib as mpl
265
+ marker_size_pt = mm_to_pt(marker_size_mm)
266
+ mpl.rcParams["lines.markersize"] = marker_size_pt
267
+
268
+ # Configure tick parameters
269
+ tick_pad_pt = style.get("tick_pad_pt", 2.0)
270
+ tick_direction = style.get("tick_direction", "out")
271
+ ax.tick_params(
272
+ direction=tick_direction,
273
+ length=mm_to_pt(style.get("tick_length_mm", 1.0)),
274
+ width=mm_to_pt(style.get("tick_thickness_mm", 0.2)),
275
+ pad=tick_pad_pt,
276
+ top=False,
277
+ right=False,
278
+ )
279
+
280
+ # Apply font sizes and family (with font availability check)
281
+ axis_fs = style.get("axis_font_size_pt", 8)
282
+ tick_fs = style.get("tick_font_size_pt", 7)
283
+ title_fs = style.get("title_font_size_pt", 9)
284
+ legend_fs = style.get("legend_font_size_pt", 7)
285
+ label_pad_pt = style.get("label_pad_pt", 2.0)
286
+ requested_font = style.get("font_family", "Arial")
287
+ font_family = check_font(requested_font)
288
+
289
+ ax.xaxis.label.set_fontsize(axis_fs)
290
+ ax.xaxis.label.set_fontfamily(font_family)
291
+ ax.xaxis.labelpad = label_pad_pt
292
+ ax.yaxis.label.set_fontsize(axis_fs)
293
+ ax.yaxis.label.set_fontfamily(font_family)
294
+ ax.yaxis.labelpad = label_pad_pt
295
+
296
+ for label in ax.get_xticklabels() + ax.get_yticklabels():
297
+ label.set_fontsize(tick_fs)
298
+ label.set_fontfamily(font_family)
299
+
300
+ # Set title font, size, and padding
301
+ ax.title.set_fontfamily(font_family)
302
+ ax.title.set_fontsize(title_fs)
303
+ title_pad_pt = style.get("title_pad_pt", 4.0)
304
+ ax.set_title(ax.get_title(), pad=title_pad_pt)
305
+
306
+ # Set legend font size and background via rcParams (for future legends)
307
+ import matplotlib as mpl
308
+ mpl.rcParams['legend.fontsize'] = legend_fs
309
+ mpl.rcParams['legend.title_fontsize'] = legend_fs
310
+
311
+ # Set legend colors from theme
312
+ theme = style.get("theme", "light")
313
+ theme_colors = style.get("theme_colors", None)
314
+ if theme_colors:
315
+ legend_bg = theme_colors.get("legend_bg", theme_colors.get("axes_bg", "white"))
316
+ text_color = theme_colors.get("text", "black")
317
+ spine_color = theme_colors.get("spine", "black")
318
+ else:
319
+ theme_dict = THEME_COLORS.get(theme, THEME_COLORS["light"])
320
+ legend_bg = theme_dict.get("legend_bg", "white")
321
+ text_color = theme_dict.get("text", "black")
322
+ spine_color = theme_dict.get("spine", "black")
323
+
324
+ # Handle transparent backgrounds
325
+ if str(legend_bg).lower() in ("none", "transparent"):
326
+ mpl.rcParams['legend.facecolor'] = 'none'
327
+ mpl.rcParams['legend.framealpha'] = 0
328
+ else:
329
+ mpl.rcParams['legend.facecolor'] = legend_bg
330
+ mpl.rcParams['legend.framealpha'] = 1.0
331
+
332
+ # Set legend text and edge colors
333
+ mpl.rcParams['legend.edgecolor'] = spine_color
334
+ mpl.rcParams['legend.labelcolor'] = text_color
335
+
336
+ legend = ax.get_legend()
337
+ if legend is not None:
338
+ for text in legend.get_texts():
339
+ text.set_fontsize(legend_fs)
340
+ text.set_fontfamily(font_family)
341
+
342
+ # Configure grid
343
+ if style.get("grid", False):
344
+ ax.grid(True, alpha=0.3)
345
+ else:
346
+ ax.grid(False)
347
+
348
+ # Configure number of ticks
349
+ n_ticks = style.get("n_ticks")
350
+ if n_ticks is not None:
351
+ from matplotlib.ticker import MaxNLocator
352
+ ax.xaxis.set_major_locator(MaxNLocator(nbins=n_ticks))
353
+ ax.yaxis.set_major_locator(MaxNLocator(nbins=n_ticks))
354
+
355
+ # Apply color palette to both rcParams and this specific axes
356
+ color_palette = style.get("color_palette")
357
+ if color_palette is not None:
358
+ import matplotlib as mpl
359
+ # Normalize colors (RGB 0-255 to 0-1)
360
+ normalized_palette = []
361
+ for c in color_palette:
362
+ if isinstance(c, (list, tuple)) and len(c) >= 3:
363
+ # Check if already normalized
364
+ if all(v <= 1.0 for v in c):
365
+ normalized_palette.append(tuple(c))
366
+ else:
367
+ normalized_palette.append(tuple(v / 255.0 for v in c))
368
+ else:
369
+ normalized_palette.append(c)
370
+ # Set rcParams for future axes
371
+ mpl.rcParams['axes.prop_cycle'] = mpl.cycler(color=normalized_palette)
372
+ # Also set the color cycle on this specific axes (axes cache cycler at creation)
373
+ ax.set_prop_cycle(color=normalized_palette)
374
+
375
+ # Store style in axes for reference
376
+ if not hasattr(ax, "_figrecipe_style"):
377
+ ax._figrecipe_style = {}
378
+ ax._figrecipe_style.update(style)
379
+
380
+ return trace_lw_pt
381
+
382
+
383
+ if __name__ == "__main__":
384
+ import matplotlib.pyplot as plt
385
+ import numpy as np
386
+
387
+ # Test styling
388
+ print("Testing style application...")
389
+
390
+ fig, ax = plt.subplots(figsize=(4, 3))
391
+
392
+ style = {
393
+ "axes_thickness_mm": 0.2,
394
+ "trace_thickness_mm": 0.3,
395
+ "tick_length_mm": 1.0,
396
+ "tick_thickness_mm": 0.2,
397
+ "axis_font_size_pt": 8,
398
+ "tick_font_size_pt": 7,
399
+ "theme": "light",
400
+ }
401
+
402
+ trace_lw = apply_style_mm(ax, style)
403
+
404
+ x = np.linspace(0, 2 * np.pi, 100)
405
+ ax.plot(x, np.sin(x), lw=trace_lw)
406
+ ax.set_xlabel("X axis")
407
+ ax.set_ylabel("Y axis")
408
+ ax.set_title("Test Plot")
409
+
410
+ plt.savefig("/tmp/test_style.png", dpi=300, bbox_inches="tight")
411
+ print("Saved to /tmp/test_style.png")
412
+ plt.close()