figrecipe 0.6.0__py3-none-any.whl → 0.9.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.
Files changed (269) hide show
  1. figrecipe/__init__.py +161 -1030
  2. figrecipe/__main__.py +12 -0
  3. figrecipe/_api/__init__.py +48 -0
  4. figrecipe/_api/_extract.py +108 -0
  5. figrecipe/_api/_notebook.py +61 -0
  6. figrecipe/_api/_panel.py +113 -0
  7. figrecipe/_api/_save.py +287 -0
  8. figrecipe/_api/_seaborn_proxy.py +34 -0
  9. figrecipe/_api/_style_manager.py +153 -0
  10. figrecipe/_api/_subplots.py +333 -0
  11. figrecipe/_api/_validate.py +82 -0
  12. figrecipe/_cli/__init__.py +7 -0
  13. figrecipe/_cli/_compose.py +87 -0
  14. figrecipe/_cli/_convert.py +117 -0
  15. figrecipe/_cli/_crop.py +82 -0
  16. figrecipe/_cli/_edit.py +70 -0
  17. figrecipe/_cli/_extract.py +128 -0
  18. figrecipe/_cli/_fonts.py +47 -0
  19. figrecipe/_cli/_info.py +67 -0
  20. figrecipe/_cli/_main.py +58 -0
  21. figrecipe/_cli/_reproduce.py +79 -0
  22. figrecipe/_cli/_style.py +77 -0
  23. figrecipe/_cli/_validate.py +66 -0
  24. figrecipe/_cli/_version.py +50 -0
  25. figrecipe/_composition/__init__.py +32 -0
  26. figrecipe/_composition/_alignment.py +452 -0
  27. figrecipe/_composition/_compose.py +179 -0
  28. figrecipe/_composition/_import_axes.py +127 -0
  29. figrecipe/_composition/_visibility.py +125 -0
  30. figrecipe/_dev/__init__.py +4 -93
  31. figrecipe/_dev/_plotters.py +76 -0
  32. figrecipe/_dev/_run_demos.py +56 -0
  33. figrecipe/_dev/browser/__init__.py +69 -0
  34. figrecipe/_dev/browser/_audio.py +240 -0
  35. figrecipe/_dev/browser/_caption.py +356 -0
  36. figrecipe/_dev/browser/_click_effect.py +146 -0
  37. figrecipe/_dev/browser/_cursor.py +196 -0
  38. figrecipe/_dev/browser/_highlight.py +105 -0
  39. figrecipe/_dev/browser/_narration.py +237 -0
  40. figrecipe/_dev/browser/_recorder.py +446 -0
  41. figrecipe/_dev/browser/_utils.py +178 -0
  42. figrecipe/_dev/browser/_video_trim/__init__.py +152 -0
  43. figrecipe/_dev/browser/_video_trim/_detection.py +223 -0
  44. figrecipe/_dev/browser/_video_trim/_markers.py +140 -0
  45. figrecipe/_dev/demo_plotters/__init__.py +35 -166
  46. figrecipe/_dev/demo_plotters/_categories.py +81 -0
  47. figrecipe/_dev/demo_plotters/_figure_creators.py +119 -0
  48. figrecipe/_dev/demo_plotters/_helpers.py +31 -0
  49. figrecipe/_dev/demo_plotters/_registry.py +50 -0
  50. figrecipe/_dev/demo_plotters/bar_categorical/__init__.py +4 -0
  51. figrecipe/_dev/demo_plotters/contour_surface/__init__.py +4 -0
  52. figrecipe/_dev/demo_plotters/distribution/__init__.py +4 -0
  53. figrecipe/_dev/demo_plotters/image_matrix/__init__.py +4 -0
  54. figrecipe/_dev/demo_plotters/line_curve/__init__.py +4 -0
  55. figrecipe/_dev/demo_plotters/{plot_plot.py → line_curve/plot_plot.py} +3 -2
  56. figrecipe/_dev/demo_plotters/scatter_points/__init__.py +4 -0
  57. figrecipe/_dev/demo_plotters/special/__init__.py +4 -0
  58. figrecipe/_dev/demo_plotters/{plot_pie.py → special/plot_pie.py} +5 -1
  59. figrecipe/_dev/demo_plotters/spectral_signal/__init__.py +4 -0
  60. figrecipe/_dev/demo_plotters/vector_flow/__init__.py +4 -0
  61. figrecipe/_editor/__init__.py +61 -13
  62. figrecipe/_editor/_bbox/__init__.py +43 -0
  63. figrecipe/_editor/_bbox/_collections.py +177 -0
  64. figrecipe/_editor/_bbox/_elements.py +159 -0
  65. figrecipe/_editor/_bbox/_extract.py +402 -0
  66. figrecipe/_editor/_bbox/_extract_axes.py +370 -0
  67. figrecipe/_editor/_bbox/_extract_text.py +466 -0
  68. figrecipe/_editor/_bbox/_lines.py +173 -0
  69. figrecipe/_editor/_bbox/_transforms.py +146 -0
  70. figrecipe/_editor/_call_overrides.py +183 -0
  71. figrecipe/_editor/_datatable_plot_handlers.py +249 -0
  72. figrecipe/_editor/_figure_layout.py +211 -0
  73. figrecipe/_editor/_flask_app.py +200 -1030
  74. figrecipe/_editor/_helpers.py +251 -0
  75. figrecipe/_editor/_hitmap/__init__.py +76 -0
  76. figrecipe/_editor/_hitmap/_artists/__init__.py +21 -0
  77. figrecipe/_editor/_hitmap/_artists/_collections.py +345 -0
  78. figrecipe/_editor/_hitmap/_artists/_images.py +68 -0
  79. figrecipe/_editor/_hitmap/_artists/_lines.py +107 -0
  80. figrecipe/_editor/_hitmap/_artists/_patches.py +163 -0
  81. figrecipe/_editor/_hitmap/_artists/_text.py +190 -0
  82. figrecipe/_editor/_hitmap/_colors.py +181 -0
  83. figrecipe/_editor/_hitmap/_detect.py +194 -0
  84. figrecipe/_editor/_hitmap/_restore.py +154 -0
  85. figrecipe/_editor/_hitmap_main.py +182 -0
  86. figrecipe/_editor/_overrides.py +4 -1
  87. figrecipe/_editor/_plot_types_registry.py +190 -0
  88. figrecipe/_editor/_preferences.py +135 -0
  89. figrecipe/_editor/_render_overrides.py +507 -0
  90. figrecipe/_editor/_renderer.py +81 -186
  91. figrecipe/_editor/_routes_annotation.py +114 -0
  92. figrecipe/_editor/_routes_axis.py +482 -0
  93. figrecipe/_editor/_routes_captions.py +130 -0
  94. figrecipe/_editor/_routes_composition.py +270 -0
  95. figrecipe/_editor/_routes_core.py +126 -0
  96. figrecipe/_editor/_routes_datatable.py +364 -0
  97. figrecipe/_editor/_routes_element.py +335 -0
  98. figrecipe/_editor/_routes_files.py +443 -0
  99. figrecipe/_editor/_routes_image.py +200 -0
  100. figrecipe/_editor/_routes_snapshot.py +94 -0
  101. figrecipe/_editor/_routes_style.py +243 -0
  102. figrecipe/_editor/_templates/__init__.py +116 -1
  103. figrecipe/_editor/_templates/_html.py +154 -64
  104. figrecipe/_editor/_templates/_html_components/__init__.py +13 -0
  105. figrecipe/_editor/_templates/_html_components/_composition_toolbar.py +79 -0
  106. figrecipe/_editor/_templates/_html_components/_file_browser.py +41 -0
  107. figrecipe/_editor/_templates/_html_datatable.py +92 -0
  108. figrecipe/_editor/_templates/_scripts/__init__.py +178 -0
  109. figrecipe/_editor/_templates/_scripts/_accordion.py +328 -0
  110. figrecipe/_editor/_templates/_scripts/_annotation_drag.py +504 -0
  111. figrecipe/_editor/_templates/_scripts/_api.py +228 -0
  112. figrecipe/_editor/_templates/_scripts/_canvas_context_menu.py +182 -0
  113. figrecipe/_editor/_templates/_scripts/_captions.py +231 -0
  114. figrecipe/_editor/_templates/_scripts/_colors.py +485 -0
  115. figrecipe/_editor/_templates/_scripts/_composition.py +283 -0
  116. figrecipe/_editor/_templates/_scripts/_core.py +493 -0
  117. figrecipe/_editor/_templates/_scripts/_datatable/__init__.py +59 -0
  118. figrecipe/_editor/_templates/_scripts/_datatable/_cell_edit.py +97 -0
  119. figrecipe/_editor/_templates/_scripts/_datatable/_clipboard.py +164 -0
  120. figrecipe/_editor/_templates/_scripts/_datatable/_context_menu.py +221 -0
  121. figrecipe/_editor/_templates/_scripts/_datatable/_core.py +150 -0
  122. figrecipe/_editor/_templates/_scripts/_datatable/_editable.py +511 -0
  123. figrecipe/_editor/_templates/_scripts/_datatable/_import.py +161 -0
  124. figrecipe/_editor/_templates/_scripts/_datatable/_plot.py +261 -0
  125. figrecipe/_editor/_templates/_scripts/_datatable/_selection.py +438 -0
  126. figrecipe/_editor/_templates/_scripts/_datatable/_table.py +256 -0
  127. figrecipe/_editor/_templates/_scripts/_datatable/_tabs.py +354 -0
  128. figrecipe/_editor/_templates/_scripts/_debug_snapshot.py +186 -0
  129. figrecipe/_editor/_templates/_scripts/_element_editor.py +325 -0
  130. figrecipe/_editor/_templates/_scripts/_files.py +429 -0
  131. figrecipe/_editor/_templates/_scripts/_files_context_menu.py +240 -0
  132. figrecipe/_editor/_templates/_scripts/_hitmap.py +512 -0
  133. figrecipe/_editor/_templates/_scripts/_image_drop.py +428 -0
  134. figrecipe/_editor/_templates/_scripts/_inspector.py +315 -0
  135. figrecipe/_editor/_templates/_scripts/_labels.py +464 -0
  136. figrecipe/_editor/_templates/_scripts/_legend_drag.py +270 -0
  137. figrecipe/_editor/_templates/_scripts/_modals.py +226 -0
  138. figrecipe/_editor/_templates/_scripts/_multi_select.py +198 -0
  139. figrecipe/_editor/_templates/_scripts/_overlays.py +292 -0
  140. figrecipe/_editor/_templates/_scripts/_panel_drag.py +505 -0
  141. figrecipe/_editor/_templates/_scripts/_panel_drag_snapshot.py +33 -0
  142. figrecipe/_editor/_templates/_scripts/_panel_position.py +463 -0
  143. figrecipe/_editor/_templates/_scripts/_panel_resize.py +230 -0
  144. figrecipe/_editor/_templates/_scripts/_panel_snap.py +307 -0
  145. figrecipe/_editor/_templates/_scripts/_region_select.py +255 -0
  146. figrecipe/_editor/_templates/_scripts/_selection.py +244 -0
  147. figrecipe/_editor/_templates/_scripts/_sync.py +242 -0
  148. figrecipe/_editor/_templates/_scripts/_tabs.py +89 -0
  149. figrecipe/_editor/_templates/_scripts/_undo_redo.py +348 -0
  150. figrecipe/_editor/_templates/_scripts/_view_mode.py +107 -0
  151. figrecipe/_editor/_templates/_scripts/_zoom.py +212 -0
  152. figrecipe/_editor/_templates/_styles/__init__.py +78 -0
  153. figrecipe/_editor/_templates/_styles/_base.py +111 -0
  154. figrecipe/_editor/_templates/_styles/_buttons.py +327 -0
  155. figrecipe/_editor/_templates/_styles/_color_input.py +123 -0
  156. figrecipe/_editor/_templates/_styles/_composition.py +87 -0
  157. figrecipe/_editor/_templates/_styles/_controls.py +430 -0
  158. figrecipe/_editor/_templates/_styles/_datatable/__init__.py +40 -0
  159. figrecipe/_editor/_templates/_styles/_datatable/_editable.py +203 -0
  160. figrecipe/_editor/_templates/_styles/_datatable/_panel.py +268 -0
  161. figrecipe/_editor/_templates/_styles/_datatable/_table.py +479 -0
  162. figrecipe/_editor/_templates/_styles/_datatable/_toolbar.py +384 -0
  163. figrecipe/_editor/_templates/_styles/_datatable/_vars.py +123 -0
  164. figrecipe/_editor/_templates/_styles/_dynamic_props.py +144 -0
  165. figrecipe/_editor/_templates/_styles/_file_browser.py +466 -0
  166. figrecipe/_editor/_templates/_styles/_forms.py +224 -0
  167. figrecipe/_editor/_templates/_styles/_hitmap.py +191 -0
  168. figrecipe/_editor/_templates/_styles/_inspector.py +90 -0
  169. figrecipe/_editor/_templates/_styles/_labels.py +118 -0
  170. figrecipe/_editor/_templates/_styles/_modals.py +127 -0
  171. figrecipe/_editor/_templates/_styles/_overlays.py +130 -0
  172. figrecipe/_editor/_templates/_styles/_preview.py +430 -0
  173. figrecipe/_editor/_templates/_styles/_selection.py +73 -0
  174. figrecipe/_editor/_templates/_styles/_spinner.py +117 -0
  175. figrecipe/_editor/static/audio/click.mp3 +0 -0
  176. figrecipe/_editor/static/click.mp3 +0 -0
  177. figrecipe/_editor/static/icons/favicon.ico +0 -0
  178. figrecipe/_integrations/__init__.py +17 -0
  179. figrecipe/_integrations/_scitex_stats.py +298 -0
  180. figrecipe/_params/_DECORATION_METHODS.py +8 -0
  181. figrecipe/_recorder.py +63 -109
  182. figrecipe/_recorder_utils.py +124 -0
  183. figrecipe/_reproducer/__init__.py +18 -0
  184. figrecipe/_reproducer/_core.py +509 -0
  185. figrecipe/_reproducer/_custom_plots.py +279 -0
  186. figrecipe/_reproducer/_seaborn.py +100 -0
  187. figrecipe/_reproducer/_violin.py +186 -0
  188. figrecipe/_signatures/_kwargs.py +273 -0
  189. figrecipe/_signatures/_loader.py +21 -423
  190. figrecipe/_signatures/_parsing.py +147 -0
  191. figrecipe/_utils/__init__.py +3 -0
  192. figrecipe/_utils/_bundle.py +205 -0
  193. figrecipe/_wrappers/_axes.py +252 -895
  194. figrecipe/_wrappers/_axes_helpers.py +136 -0
  195. figrecipe/_wrappers/_axes_plots.py +418 -0
  196. figrecipe/_wrappers/_axes_seaborn.py +157 -0
  197. figrecipe/_wrappers/_caption_generator.py +218 -0
  198. figrecipe/_wrappers/_figure.py +188 -1
  199. figrecipe/_wrappers/_panel_labels.py +127 -0
  200. figrecipe/_wrappers/_plot_helpers.py +143 -0
  201. figrecipe/_wrappers/_stat_annotation.py +274 -0
  202. figrecipe/_wrappers/_violin_helpers.py +180 -0
  203. figrecipe/styles/__init__.py +8 -6
  204. figrecipe/styles/_dotdict.py +72 -0
  205. figrecipe/styles/_finalize.py +134 -0
  206. figrecipe/styles/_fonts.py +77 -0
  207. figrecipe/styles/_kwargs_converter.py +178 -0
  208. figrecipe/styles/_plot_styles.py +209 -0
  209. figrecipe/styles/_style_applier.py +42 -480
  210. figrecipe/styles/_style_loader.py +16 -192
  211. figrecipe/styles/_themes.py +151 -0
  212. figrecipe/styles/presets/MATPLOTLIB.yaml +2 -1
  213. figrecipe/styles/presets/SCITEX.yaml +40 -28
  214. figrecipe-0.9.0.dist-info/METADATA +427 -0
  215. figrecipe-0.9.0.dist-info/RECORD +277 -0
  216. figrecipe-0.9.0.dist-info/entry_points.txt +2 -0
  217. figrecipe/_editor/_bbox.py +0 -978
  218. figrecipe/_editor/_hitmap.py +0 -937
  219. figrecipe/_editor/_templates/_scripts.py +0 -2778
  220. figrecipe/_editor/_templates/_styles.py +0 -1326
  221. figrecipe/_reproducer.py +0 -975
  222. figrecipe-0.6.0.dist-info/METADATA +0 -394
  223. figrecipe-0.6.0.dist-info/RECORD +0 -90
  224. /figrecipe/_dev/demo_plotters/{plot_bar.py → bar_categorical/plot_bar.py} +0 -0
  225. /figrecipe/_dev/demo_plotters/{plot_barh.py → bar_categorical/plot_barh.py} +0 -0
  226. /figrecipe/_dev/demo_plotters/{plot_contour.py → contour_surface/plot_contour.py} +0 -0
  227. /figrecipe/_dev/demo_plotters/{plot_contourf.py → contour_surface/plot_contourf.py} +0 -0
  228. /figrecipe/_dev/demo_plotters/{plot_tricontour.py → contour_surface/plot_tricontour.py} +0 -0
  229. /figrecipe/_dev/demo_plotters/{plot_tricontourf.py → contour_surface/plot_tricontourf.py} +0 -0
  230. /figrecipe/_dev/demo_plotters/{plot_tripcolor.py → contour_surface/plot_tripcolor.py} +0 -0
  231. /figrecipe/_dev/demo_plotters/{plot_triplot.py → contour_surface/plot_triplot.py} +0 -0
  232. /figrecipe/_dev/demo_plotters/{plot_boxplot.py → distribution/plot_boxplot.py} +0 -0
  233. /figrecipe/_dev/demo_plotters/{plot_ecdf.py → distribution/plot_ecdf.py} +0 -0
  234. /figrecipe/_dev/demo_plotters/{plot_hist.py → distribution/plot_hist.py} +0 -0
  235. /figrecipe/_dev/demo_plotters/{plot_hist2d.py → distribution/plot_hist2d.py} +0 -0
  236. /figrecipe/_dev/demo_plotters/{plot_violinplot.py → distribution/plot_violinplot.py} +0 -0
  237. /figrecipe/_dev/demo_plotters/{plot_hexbin.py → image_matrix/plot_hexbin.py} +0 -0
  238. /figrecipe/_dev/demo_plotters/{plot_imshow.py → image_matrix/plot_imshow.py} +0 -0
  239. /figrecipe/_dev/demo_plotters/{plot_matshow.py → image_matrix/plot_matshow.py} +0 -0
  240. /figrecipe/_dev/demo_plotters/{plot_pcolor.py → image_matrix/plot_pcolor.py} +0 -0
  241. /figrecipe/_dev/demo_plotters/{plot_pcolormesh.py → image_matrix/plot_pcolormesh.py} +0 -0
  242. /figrecipe/_dev/demo_plotters/{plot_spy.py → image_matrix/plot_spy.py} +0 -0
  243. /figrecipe/_dev/demo_plotters/{plot_errorbar.py → line_curve/plot_errorbar.py} +0 -0
  244. /figrecipe/_dev/demo_plotters/{plot_fill.py → line_curve/plot_fill.py} +0 -0
  245. /figrecipe/_dev/demo_plotters/{plot_fill_between.py → line_curve/plot_fill_between.py} +0 -0
  246. /figrecipe/_dev/demo_plotters/{plot_fill_betweenx.py → line_curve/plot_fill_betweenx.py} +0 -0
  247. /figrecipe/_dev/demo_plotters/{plot_stackplot.py → line_curve/plot_stackplot.py} +0 -0
  248. /figrecipe/_dev/demo_plotters/{plot_stairs.py → line_curve/plot_stairs.py} +0 -0
  249. /figrecipe/_dev/demo_plotters/{plot_step.py → line_curve/plot_step.py} +0 -0
  250. /figrecipe/_dev/demo_plotters/{plot_scatter.py → scatter_points/plot_scatter.py} +0 -0
  251. /figrecipe/_dev/demo_plotters/{plot_eventplot.py → special/plot_eventplot.py} +0 -0
  252. /figrecipe/_dev/demo_plotters/{plot_loglog.py → special/plot_loglog.py} +0 -0
  253. /figrecipe/_dev/demo_plotters/{plot_semilogx.py → special/plot_semilogx.py} +0 -0
  254. /figrecipe/_dev/demo_plotters/{plot_semilogy.py → special/plot_semilogy.py} +0 -0
  255. /figrecipe/_dev/demo_plotters/{plot_stem.py → special/plot_stem.py} +0 -0
  256. /figrecipe/_dev/demo_plotters/{plot_acorr.py → spectral_signal/plot_acorr.py} +0 -0
  257. /figrecipe/_dev/demo_plotters/{plot_angle_spectrum.py → spectral_signal/plot_angle_spectrum.py} +0 -0
  258. /figrecipe/_dev/demo_plotters/{plot_cohere.py → spectral_signal/plot_cohere.py} +0 -0
  259. /figrecipe/_dev/demo_plotters/{plot_csd.py → spectral_signal/plot_csd.py} +0 -0
  260. /figrecipe/_dev/demo_plotters/{plot_magnitude_spectrum.py → spectral_signal/plot_magnitude_spectrum.py} +0 -0
  261. /figrecipe/_dev/demo_plotters/{plot_phase_spectrum.py → spectral_signal/plot_phase_spectrum.py} +0 -0
  262. /figrecipe/_dev/demo_plotters/{plot_psd.py → spectral_signal/plot_psd.py} +0 -0
  263. /figrecipe/_dev/demo_plotters/{plot_specgram.py → spectral_signal/plot_specgram.py} +0 -0
  264. /figrecipe/_dev/demo_plotters/{plot_xcorr.py → spectral_signal/plot_xcorr.py} +0 -0
  265. /figrecipe/_dev/demo_plotters/{plot_barbs.py → vector_flow/plot_barbs.py} +0 -0
  266. /figrecipe/_dev/demo_plotters/{plot_quiver.py → vector_flow/plot_quiver.py} +0 -0
  267. /figrecipe/_dev/demo_plotters/{plot_streamplot.py → vector_flow/plot_streamplot.py} +0 -0
  268. {figrecipe-0.6.0.dist-info → figrecipe-0.9.0.dist-info}/WHEEL +0 -0
  269. {figrecipe-0.6.0.dist-info → figrecipe-0.9.0.dist-info}/licenses/LICENSE +0 -0
figrecipe/__init__.py CHANGED
@@ -56,6 +56,34 @@ from matplotlib.axes import Axes
56
56
  from matplotlib.figure import Figure
57
57
  from numpy.typing import NDArray
58
58
 
59
+ # Notebook utilities
60
+ from ._api._notebook import enable_svg
61
+
62
+ # Panel label
63
+ from ._api._panel import panel_label
64
+
65
+ # Seaborn proxy
66
+ from ._api._seaborn_proxy import sns
67
+
68
+ # Composition API
69
+ from ._composition import (
70
+ AlignmentMode,
71
+ align_panels,
72
+ compose,
73
+ distribute_panels,
74
+ hide_panel,
75
+ import_axes,
76
+ show_panel,
77
+ smart_align,
78
+ toggle_panel,
79
+ )
80
+
81
+ # scitex.stats integration
82
+ from ._integrations import (
83
+ SCITEX_STATS_AVAILABLE,
84
+ annotate_from_stats,
85
+ from_scitex_stats,
86
+ )
59
87
  from ._recorder import CallRecord, FigureRecord
60
88
  from ._reproducer import get_recipe_info
61
89
  from ._reproducer import reproduce as _reproduce
@@ -71,93 +99,22 @@ from ._utils._units import (
71
99
  )
72
100
  from ._validator import ValidationResult
73
101
  from ._wrappers import RecordingAxes, RecordingFigure
74
- from ._wrappers._figure import create_recording_subplots
75
102
  from .styles._style_applier import check_font, list_available_fonts
76
103
 
77
- # Notebook display format flag (set once per session)
78
- _notebook_format_set = False
79
-
80
-
81
- def _enable_notebook_svg():
82
- """Enable SVG format for Jupyter notebook display.
83
-
84
- This provides crisp vector graphics at any zoom level.
85
- Called automatically when load_style() or subplots() is used.
86
- """
87
- global _notebook_format_set
88
- if _notebook_format_set:
89
- return
90
-
91
- try:
92
- # Method 1: matplotlib_inline (IPython 7.0+, JupyterLab)
93
- from matplotlib_inline.backend_inline import set_matplotlib_formats
94
-
95
- set_matplotlib_formats("svg")
96
- _notebook_format_set = True
97
- except (ImportError, Exception):
98
- try:
99
- # Method 2: IPython config (older IPython)
100
- from IPython import get_ipython
101
-
102
- ipython = get_ipython()
103
- if ipython is not None and hasattr(ipython, "kernel"):
104
- # Only run in actual Jupyter kernel, not IPython console
105
- ipython.run_line_magic(
106
- "config", "InlineBackend.figure_formats = ['svg']"
107
- )
108
- _notebook_format_set = True
109
- except Exception:
110
- pass # Not in Jupyter environment or method not available
111
-
112
-
113
- def enable_svg():
114
- """Manually enable SVG format for Jupyter notebook display.
115
-
116
- Call this if figures appear pixelated in notebooks.
117
-
118
- Examples
119
- --------
120
- >>> import figrecipe as fr
121
- >>> fr.enable_svg() # Enable SVG rendering
122
- >>> fig, ax = fr.subplots() # Now renders as crisp SVG
123
- """
124
- global _notebook_format_set
125
- _notebook_format_set = False # Force re-application
126
- _enable_notebook_svg()
127
-
128
-
129
- # Lazy import for seaborn to avoid hard dependency
130
- _sns_recorder = None
131
-
132
-
133
- def _get_sns():
134
- """Get the seaborn recorder (lazy initialization)."""
135
- global _sns_recorder
136
- if _sns_recorder is None:
137
- from ._seaborn import get_seaborn_recorder
138
-
139
- _sns_recorder = get_seaborn_recorder()
140
- return _sns_recorder
141
-
142
-
143
- class _SeabornProxy:
144
- """Proxy object for seaborn access via ps.sns."""
145
-
146
- def __getattr__(self, name: str):
147
- return getattr(_get_sns(), name)
148
-
104
+ try:
105
+ from importlib.metadata import version as _get_version
149
106
 
150
- # Create seaborn proxy
151
- sns = _SeabornProxy()
152
-
153
- __version__ = "0.4.0"
107
+ __version__ = _get_version("figrecipe")
108
+ except Exception:
109
+ __version__ = "0.0.0" # Fallback for development
154
110
  __all__ = [
155
111
  # Main API
156
112
  "subplots",
157
113
  "save",
158
114
  "reproduce",
115
+ "load", # Alias for reproduce
159
116
  "info",
160
- "load",
117
+ "load_record",
161
118
  "extract_data",
162
119
  "validate",
163
120
  # GUI Editor
@@ -192,151 +149,34 @@ __all__ = [
192
149
  "crop",
193
150
  # Panel labels
194
151
  "panel_label",
152
+ # Composition
153
+ "compose",
154
+ "import_axes",
155
+ "hide_panel",
156
+ "show_panel",
157
+ "toggle_panel",
158
+ # Alignment
159
+ "AlignmentMode",
160
+ "align_panels",
161
+ "distribute_panels",
162
+ "smart_align",
163
+ # scitex.stats integration
164
+ "from_scitex_stats",
165
+ "annotate_from_stats",
166
+ "SCITEX_STATS_AVAILABLE",
195
167
  # Version
196
168
  "__version__",
197
169
  ]
198
170
 
199
171
 
200
- # Lazy imports for style system
201
- _style_cache = None
202
-
203
-
204
- def load_style(style="SCITEX", dark=False):
205
- """Load style configuration and apply it globally.
206
-
207
- After calling this function, subsequent `subplots()` calls will
208
- automatically use the loaded style (fonts, colors, theme, etc.).
209
-
210
- Parameters
211
- ----------
212
- style : str, Path, bool, or None
213
- One of:
214
- - "SCITEX" / "FIGRECIPE": Scientific publication style (default)
215
- - "MATPLOTLIB": Vanilla matplotlib defaults
216
- - Path to custom YAML file: "/path/to/my_style.yaml"
217
- - None or False: Unload style (reset to matplotlib defaults)
218
- dark : bool, optional
219
- If True, apply dark theme transformation (default: False).
220
- Equivalent to appending "_DARK" to preset name.
221
-
222
- Returns
223
- -------
224
- DotDict or None
225
- Style configuration with dot-notation access.
226
- Returns None if style is unloaded.
227
-
228
- Examples
229
- --------
230
- >>> import figrecipe as fr
231
-
232
- >>> # Load scientific style (default)
233
- >>> fr.load_style()
234
- >>> fr.load_style("SCITEX") # explicit
235
-
236
- >>> # Load dark theme
237
- >>> fr.load_style("SCITEX_DARK")
238
- >>> fr.load_style("SCITEX", dark=True) # equivalent
239
-
240
- >>> # Reset to vanilla matplotlib
241
- >>> fr.load_style(None) # unload
242
- >>> fr.load_style(False) # unload
243
- >>> fr.load_style("MATPLOTLIB") # explicit vanilla
244
-
245
- >>> # Access style values
246
- >>> style = fr.load_style("SCITEX")
247
- >>> style.axes.width_mm
248
- 40
249
- """
250
- from .styles import load_style as _load_style
251
-
252
- return _load_style(style, dark=dark)
253
-
254
-
255
- def unload_style():
256
- """Unload the current style and reset to matplotlib defaults.
257
-
258
- After calling this, subsequent `subplots()` calls will use vanilla
259
- matplotlib behavior without FigRecipe styling.
260
-
261
- Examples
262
- --------
263
- >>> import figrecipe as fr
264
- >>> fr.load_style("SCITEX") # Apply scientific style
265
- >>> fig, ax = fr.subplots() # Styled
266
- >>> fr.unload_style() # Reset to matplotlib defaults
267
- >>> fig, ax = fr.subplots() # Vanilla matplotlib
268
- """
269
- from .styles import unload_style as _unload_style
270
-
271
- _unload_style()
272
-
273
-
274
- def list_presets():
275
- """List available style presets.
276
-
277
- Returns
278
- -------
279
- list of str
280
- Names of available presets.
281
-
282
- Examples
283
- --------
284
- >>> import figrecipe as ps
285
- >>> ps.list_presets()
286
- ['MINIMAL', 'PRESENTATION', 'SCIENTIFIC']
287
- """
288
- from .styles import list_presets as _list_presets
289
-
290
- return _list_presets()
291
-
292
-
293
- def apply_style(ax, style=None):
294
- """Apply mm-based styling to an axes.
295
-
296
- Parameters
297
- ----------
298
- ax : matplotlib.axes.Axes
299
- Target axes to apply styling to.
300
- style : dict or DotDict, optional
301
- Style configuration. If None, uses default FIGRECIPE_STYLE.
302
-
303
- Returns
304
- -------
305
- float
306
- Trace line width in points.
307
-
308
- Examples
309
- --------
310
- >>> import figrecipe as ps
311
- >>> import matplotlib.pyplot as plt
312
- >>> fig, ax = plt.subplots()
313
- >>> trace_lw = ps.apply_style(ax)
314
- >>> ax.plot(x, y, lw=trace_lw)
315
- """
316
- from .styles import apply_style_mm, get_style, to_subplots_kwargs
317
-
318
- if style is None:
319
- style = to_subplots_kwargs(get_style())
320
- elif hasattr(style, "to_subplots_kwargs"):
321
- style = style.to_subplots_kwargs()
322
- return apply_style_mm(ax, style)
323
-
324
-
325
- class _StyleProxy:
326
- """Proxy object for lazy style loading."""
327
-
328
- def __getattr__(self, name):
329
- from .styles import STYLE
330
-
331
- return getattr(STYLE, name)
332
-
333
- def to_subplots_kwargs(self):
334
- from .styles import to_subplots_kwargs
335
-
336
- return to_subplots_kwargs()
337
-
338
-
339
- STYLE = _StyleProxy()
172
+ # Style management
173
+ from ._api._style_manager import (
174
+ STYLE,
175
+ apply_style,
176
+ list_presets,
177
+ load_style,
178
+ unload_style,
179
+ )
340
180
 
341
181
 
342
182
  def subplots(
@@ -354,6 +194,8 @@ def subplots(
354
194
  # Style parameters
355
195
  style: Optional[Dict[str, Any]] = None,
356
196
  apply_style_mm: bool = True,
197
+ # Panel labels (None = use style default, True/False = explicit)
198
+ panel_labels: Optional[bool] = None,
357
199
  **kwargs,
358
200
  ) -> Tuple[RecordingFigure, Union[RecordingAxes, NDArray]]:
359
201
  """Create a figure with recording-enabled axes.
@@ -365,281 +207,51 @@ def subplots(
365
207
 
366
208
  Parameters
367
209
  ----------
368
- nrows : int
369
- Number of rows of subplots.
370
- ncols : int
371
- Number of columns of subplots.
372
-
373
- MM-Control Parameters
374
- ---------------------
375
- axes_width_mm : float, optional
376
- Axes width in mm. If provided, overrides figsize.
377
- axes_height_mm : float, optional
378
- Axes height in mm.
379
- margin_left_mm : float, optional
380
- Left margin in mm (default: 15).
381
- margin_right_mm : float, optional
382
- Right margin in mm (default: 5).
383
- margin_bottom_mm : float, optional
384
- Bottom margin in mm (default: 12).
385
- margin_top_mm : float, optional
386
- Top margin in mm (default: 8).
387
- space_w_mm : float, optional
388
- Horizontal spacing between axes in mm (default: 8).
389
- space_h_mm : float, optional
390
- Vertical spacing between axes in mm (default: 10).
391
-
392
- Style Parameters
393
- ----------------
210
+ nrows, ncols : int
211
+ Number of rows and columns of subplots.
212
+ axes_width_mm, axes_height_mm : float, optional
213
+ Axes dimensions in mm.
214
+ margin_left_mm, margin_right_mm : float, optional
215
+ Left/right margins in mm.
216
+ margin_bottom_mm, margin_top_mm : float, optional
217
+ Bottom/top margins in mm.
218
+ space_w_mm, space_h_mm : float, optional
219
+ Horizontal/vertical spacing between axes in mm.
394
220
  style : dict, optional
395
- Style configuration dictionary or result of load_style().
221
+ Style configuration dictionary.
396
222
  apply_style_mm : bool
397
- If True (default), apply loaded style to axes after creation.
398
- Set to False to disable automatic style application.
399
-
223
+ If True (default), apply loaded style to axes.
224
+ panel_labels : bool or None
225
+ If True, add panel labels (A, B, C, ...).
400
226
  **kwargs
401
- Additional arguments passed to plt.subplots() (e.g., figsize, dpi).
227
+ Additional arguments passed to plt.subplots().
402
228
 
403
229
  Returns
404
230
  -------
405
231
  fig : RecordingFigure
406
232
  Wrapped figure object.
407
233
  axes : RecordingAxes or ndarray
408
- Wrapped axes (single for 1x1, numpy array otherwise matching matplotlib).
409
-
410
- Examples
411
- --------
412
- Basic usage:
413
-
414
- >>> import figrecipe as ps
415
- >>> fig, ax = ps.subplots()
416
- >>> ax.plot([1, 2, 3], [4, 5, 6], color='blue')
417
- >>> ps.save(fig, 'simple.yaml')
418
-
419
- MM-based layout:
420
-
421
- >>> fig, ax = ps.subplots(
422
- ... axes_width_mm=40,
423
- ... axes_height_mm=28,
424
- ... margin_left_mm=15,
425
- ... margin_bottom_mm=12,
426
- ... )
427
-
428
- With style (automatically applied):
429
-
430
- >>> ps.load_style("FIGRECIPE_DARK") # Load dark theme
431
- >>> fig, ax = ps.subplots() # Style applied automatically
234
+ Wrapped axes.
432
235
  """
433
- # Get global style for default values (if loaded)
434
- from .styles._style_loader import _STYLE_CACHE
435
-
436
- global_style = _STYLE_CACHE
437
-
438
- # Helper to get value with priority: explicit > global style > hardcoded default
439
- def _get_mm(explicit, style_path, default):
440
- if explicit is not None:
441
- return explicit
442
- if global_style is not None:
443
- try:
444
- val = global_style
445
- for key in style_path:
446
- val = (
447
- val.get(key)
448
- if isinstance(val, dict)
449
- else getattr(val, key, None)
450
- )
451
- if val is None:
452
- break
453
- if val is not None:
454
- return val
455
- except (KeyError, AttributeError):
456
- pass
457
- return default
458
-
459
- # Check if mm-based layout is requested (explicit OR from global style)
460
- has_explicit_mm = any(
461
- [
462
- axes_width_mm is not None,
463
- axes_height_mm is not None,
464
- margin_left_mm is not None,
465
- margin_right_mm is not None,
466
- margin_bottom_mm is not None,
467
- margin_top_mm is not None,
468
- space_w_mm is not None,
469
- space_h_mm is not None,
470
- ]
236
+ from ._api._subplots import create_subplots
237
+
238
+ return create_subplots(
239
+ nrows=nrows,
240
+ ncols=ncols,
241
+ axes_width_mm=axes_width_mm,
242
+ axes_height_mm=axes_height_mm,
243
+ margin_left_mm=margin_left_mm,
244
+ margin_right_mm=margin_right_mm,
245
+ margin_bottom_mm=margin_bottom_mm,
246
+ margin_top_mm=margin_top_mm,
247
+ space_w_mm=space_w_mm,
248
+ space_h_mm=space_h_mm,
249
+ style=style,
250
+ apply_style_mm=apply_style_mm,
251
+ panel_labels=panel_labels,
252
+ **kwargs,
471
253
  )
472
254
 
473
- # Also use mm layout if global style has mm values
474
- has_style_mm = False
475
- if global_style is not None:
476
- try:
477
- has_style_mm = (
478
- global_style.get("axes", {}).get("width_mm") is not None
479
- or getattr(getattr(global_style, "axes", None), "width_mm", None)
480
- is not None
481
- )
482
- except (KeyError, AttributeError):
483
- pass
484
-
485
- use_mm_layout = has_explicit_mm or has_style_mm
486
-
487
- if use_mm_layout and "figsize" not in kwargs:
488
- # Get mm values: explicit params > global style > hardcoded defaults
489
- aw = _get_mm(axes_width_mm, ["axes", "width_mm"], 40)
490
- ah = _get_mm(axes_height_mm, ["axes", "height_mm"], 28)
491
- ml = _get_mm(margin_left_mm, ["margins", "left_mm"], 15)
492
- mr = _get_mm(margin_right_mm, ["margins", "right_mm"], 5)
493
- mb = _get_mm(margin_bottom_mm, ["margins", "bottom_mm"], 12)
494
- mt = _get_mm(margin_top_mm, ["margins", "top_mm"], 8)
495
- sw = _get_mm(space_w_mm, ["spacing", "horizontal_mm"], 8)
496
- sh = _get_mm(space_h_mm, ["spacing", "vertical_mm"], 10)
497
-
498
- # Calculate total figure size
499
- total_width_mm = ml + (ncols * aw) + ((ncols - 1) * sw) + mr
500
- total_height_mm = mb + (nrows * ah) + ((nrows - 1) * sh) + mt
501
-
502
- # Convert to inches and set figsize
503
- kwargs["figsize"] = (mm_to_inch(total_width_mm), mm_to_inch(total_height_mm))
504
-
505
- # Store mm metadata for recording (will be extracted by create_recording_subplots)
506
- mm_layout = {
507
- "axes_width_mm": aw,
508
- "axes_height_mm": ah,
509
- "margin_left_mm": ml,
510
- "margin_right_mm": mr,
511
- "margin_bottom_mm": mb,
512
- "margin_top_mm": mt,
513
- "space_w_mm": sw,
514
- "space_h_mm": sh,
515
- }
516
- else:
517
- mm_layout = None
518
-
519
- # Apply DPI from global style if not explicitly provided
520
- if "dpi" not in kwargs and global_style is not None:
521
- # Try figure.dpi first, then output.dpi
522
- style_dpi = None
523
- try:
524
- if hasattr(global_style, "figure") and hasattr(global_style.figure, "dpi"):
525
- style_dpi = global_style.figure.dpi
526
- elif hasattr(global_style, "output") and hasattr(
527
- global_style.output, "dpi"
528
- ):
529
- style_dpi = global_style.output.dpi
530
- except (KeyError, AttributeError):
531
- pass
532
- if style_dpi is not None:
533
- kwargs["dpi"] = style_dpi
534
-
535
- # Handle style parameter
536
- if style is not None:
537
- if hasattr(style, "to_subplots_kwargs"):
538
- # Merge style kwargs (style values are overridden by explicit params)
539
- style_kwargs = style.to_subplots_kwargs()
540
- for key, value in style_kwargs.items():
541
- if key not in kwargs:
542
- kwargs[key] = value
543
-
544
- # Check if style specifies constrained_layout
545
- style_constrained = False
546
- if global_style is not None:
547
- from .styles._style_loader import to_subplots_kwargs
548
-
549
- style_dict_check = to_subplots_kwargs(global_style)
550
- style_constrained = style_dict_check.get("constrained_layout", False)
551
-
552
- # Use constrained_layout if: style specifies it, or non-mm layout (better auto-spacing)
553
- if "constrained_layout" not in kwargs:
554
- if style_constrained:
555
- kwargs["constrained_layout"] = True
556
- elif not use_mm_layout:
557
- kwargs["constrained_layout"] = True
558
-
559
- # Create the recording subplots
560
- fig, axes = create_recording_subplots(nrows, ncols, **kwargs)
561
-
562
- # Record constrained_layout setting for reproduction
563
- fig.record.constrained_layout = kwargs.get("constrained_layout", False)
564
-
565
- # Store mm_layout metadata on figure for serialization
566
- # Skip mm-based layout if constrained_layout is True (they're incompatible)
567
- use_constrained = kwargs.get("constrained_layout", False)
568
- if mm_layout is not None and not use_constrained:
569
- fig._mm_layout = mm_layout
570
-
571
- # Apply subplots_adjust to position axes correctly
572
- total_width_mm = ml + (ncols * aw) + ((ncols - 1) * sw) + mr
573
- total_height_mm = mb + (nrows * ah) + ((nrows - 1) * sh) + mt
574
-
575
- # Calculate relative positions (0-1 range)
576
- left = ml / total_width_mm
577
- right = 1 - (mr / total_width_mm)
578
- bottom = mb / total_height_mm
579
- top = 1 - (mt / total_height_mm)
580
-
581
- # Calculate spacing as fraction of figure size
582
- wspace = sw / aw if ncols > 1 else 0
583
- hspace = sh / ah if nrows > 1 else 0
584
-
585
- fig.fig.subplots_adjust(
586
- left=left,
587
- right=right,
588
- bottom=bottom,
589
- top=top,
590
- wspace=wspace,
591
- hspace=hspace,
592
- )
593
-
594
- # Record layout in figure record for reproduction
595
- fig.record.layout = {
596
- "left": left,
597
- "right": right,
598
- "bottom": bottom,
599
- "top": top,
600
- "wspace": wspace,
601
- "hspace": hspace,
602
- }
603
-
604
- # Apply styling if requested and a style is actually loaded
605
- style_dict = None
606
- should_apply_style = False
607
-
608
- if style is not None:
609
- # Explicit style parameter provided
610
- should_apply_style = True
611
- style_dict = (
612
- style.to_subplots_kwargs()
613
- if hasattr(style, "to_subplots_kwargs")
614
- else style
615
- )
616
- elif apply_style_mm and global_style is not None:
617
- # Use global style if loaded and has meaningful values (not MATPLOTLIB)
618
- from .styles import to_subplots_kwargs
619
-
620
- style_dict = to_subplots_kwargs(global_style)
621
- # Only apply if style has essential mm values (skip MATPLOTLIB which has all None)
622
- if style_dict and style_dict.get("axes_thickness_mm") is not None:
623
- should_apply_style = True
624
-
625
- if should_apply_style and style_dict:
626
- from .styles import apply_style_mm as _apply_style
627
-
628
- if nrows == 1 and ncols == 1:
629
- _apply_style(axes._ax, style_dict)
630
- else:
631
- # Handle 2D array of axes
632
- import numpy as np
633
-
634
- axes_array = np.array(axes)
635
- for ax in axes_array.flat:
636
- _apply_style(ax._ax if hasattr(ax, "_ax") else ax, style_dict)
637
-
638
- # Record style in figure record for reproduction
639
- fig.record.style = style_dict
640
-
641
- return fig, axes
642
-
643
255
 
644
256
  def save(
645
257
  fig: Union[RecordingFigure, Figure],
@@ -662,188 +274,46 @@ def save(
662
274
  Parameters
663
275
  ----------
664
276
  fig : RecordingFigure or Figure
665
- The figure to save. Must be a RecordingFigure for recipe saving.
277
+ The figure to save.
666
278
  path : str or Path
667
- Output path. Can be:
668
- - Image path (.png, .pdf, .svg, .jpg): Saves image + YAML recipe
669
- - YAML path (.yaml, .yml): Saves recipe + image
279
+ Output path (.png, .pdf, .svg, .yaml, etc.)
670
280
  include_data : bool
671
281
  If True, save large arrays to separate files.
672
282
  data_format : str
673
- Format for data files: 'csv' (default), 'npz', or 'inline'.
674
- - 'csv': Human-readable CSV files with dtype header
675
- - 'npz': Compressed numpy binary format (efficient)
676
- - 'inline': Store all data directly in YAML
283
+ Format for data files: 'csv', 'npz', or 'inline'.
677
284
  validate : bool
678
- If True (default), validate reproducibility after saving by
679
- reproducing the figure and comparing it to the original.
285
+ If True (default), validate reproducibility after saving.
680
286
  validate_mse_threshold : float
681
287
  Maximum acceptable MSE for validation (default: 100).
682
288
  validate_error_level : str
683
- How to handle validation failures: 'error' (default), 'warning', or 'debug'.
684
- - 'error': Raise ValueError on failure
685
- - 'warning': Emit UserWarning on failure
686
- - 'debug': Silent (check result.valid manually)
289
+ How to handle validation failures: 'error', 'warning', or 'debug'.
687
290
  verbose : bool
688
- If True (default), print save status. Set False for CI/scripts.
291
+ If True (default), print save status.
689
292
  dpi : int, optional
690
- DPI for image output. Uses style DPI or 300 if not specified.
293
+ DPI for image output.
691
294
  image_format : str, optional
692
- Image format when path is YAML ('png', 'pdf', 'svg').
693
- Uses style's output.format or 'png' if not specified.
295
+ Image format when path is YAML.
694
296
 
695
297
  Returns
696
298
  -------
697
299
  tuple
698
- (image_path, yaml_path, ValidationResult or None) tuple.
699
- ValidationResult is None when validate=False.
700
-
701
- Examples
702
- --------
703
- >>> import figrecipe as fr
704
- >>> fig, ax = fr.subplots()
705
- >>> ax.plot(x, y, color='red', id='my_data')
706
- >>>
707
- >>> # Save as PNG (also creates experiment.yaml)
708
- >>> img_path, yaml_path, result = fr.save(fig, 'experiment.png')
709
- >>>
710
- >>> # Save as YAML (also creates experiment.png)
711
- >>> img_path, yaml_path, result = fr.save(fig, 'experiment.yaml')
712
- >>>
713
- >>> # Save as PDF with custom DPI
714
- >>> fr.save(fig, 'experiment.pdf', dpi=600)
715
-
716
- Notes
717
- -----
718
- The recipe file contains:
719
- - Figure metadata (size, DPI, matplotlib version)
720
- - All plotting calls with their arguments
721
- - References to data files for large arrays
300
+ (image_path, yaml_path, ValidationResult or None)
722
301
  """
723
- path = Path(path)
724
-
725
- if not isinstance(fig, RecordingFigure):
726
- raise TypeError(
727
- "Expected RecordingFigure. Use fr.subplots() to create "
728
- "a recording-enabled figure."
729
- )
730
-
731
- # Determine image and YAML paths based on extension
732
- IMAGE_EXTENSIONS = {
733
- ".png",
734
- ".pdf",
735
- ".svg",
736
- ".jpg",
737
- ".jpeg",
738
- ".eps",
739
- ".tiff",
740
- ".tif",
741
- }
742
- YAML_EXTENSIONS = {".yaml", ".yml"}
743
-
744
- suffix_lower = path.suffix.lower()
745
-
746
- if suffix_lower in IMAGE_EXTENSIONS:
747
- # User provided image path
748
- image_path = path
749
- yaml_path = path.with_suffix(".yaml")
750
- img_format = suffix_lower[1:] # Remove leading dot
751
- elif suffix_lower in YAML_EXTENSIONS:
752
- # User provided YAML path
753
- yaml_path = path
754
- # Determine image format from style or default
755
- if image_format is not None:
756
- img_format = image_format.lower().lstrip(".")
757
- else:
758
- # Check global style for preferred format
759
- from .styles._style_loader import _STYLE_CACHE
760
-
761
- if _STYLE_CACHE is not None:
762
- try:
763
- img_format = _STYLE_CACHE.output.format.lower()
764
- except (KeyError, AttributeError):
765
- img_format = "png"
766
- else:
767
- img_format = "png"
768
- image_path = path.with_suffix(f".{img_format}")
769
- else:
770
- # Unknown extension - treat as base name, add both extensions
771
- yaml_path = path.with_suffix(".yaml")
772
- if image_format is not None:
773
- img_format = image_format.lower().lstrip(".")
774
- else:
775
- from .styles._style_loader import _STYLE_CACHE
776
-
777
- if _STYLE_CACHE is not None:
778
- try:
779
- img_format = _STYLE_CACHE.output.format.lower()
780
- except (KeyError, AttributeError):
781
- img_format = "png"
782
- else:
783
- img_format = "png"
784
- image_path = path.with_suffix(f".{img_format}")
785
-
786
- # Get DPI from style if not specified
787
- if dpi is None:
788
- from .styles._style_loader import _STYLE_CACHE
789
-
790
- if _STYLE_CACHE is not None:
791
- try:
792
- dpi = _STYLE_CACHE.output.dpi
793
- except (KeyError, AttributeError):
794
- dpi = 300
795
- else:
796
- dpi = 300
797
-
798
- # Get transparency setting from style
799
- transparent = False
800
- from .styles._style_loader import _STYLE_CACHE
801
-
802
- if _STYLE_CACHE is not None:
803
- try:
804
- transparent = _STYLE_CACHE.output.transparent
805
- except (KeyError, AttributeError):
806
- pass
807
-
808
- # Finalize tick configuration for all axes (avoids categorical axis interference)
809
- from .styles._style_applier import finalize_ticks
810
-
811
- for ax in fig.fig.get_axes():
812
- finalize_ticks(ax)
813
-
814
- # Save the image
815
- fig.fig.savefig(image_path, dpi=dpi, bbox_inches="tight", transparent=transparent)
816
-
817
- # Save the recipe
818
- saved_yaml = fig.save_recipe(
819
- yaml_path, include_data=include_data, data_format=data_format
302
+ from ._api._save import save_figure
303
+
304
+ return save_figure(
305
+ fig=fig,
306
+ path=path,
307
+ include_data=include_data,
308
+ data_format=data_format,
309
+ validate=validate,
310
+ validate_mse_threshold=validate_mse_threshold,
311
+ validate_error_level=validate_error_level,
312
+ verbose=verbose,
313
+ dpi=dpi,
314
+ image_format=image_format,
820
315
  )
821
316
 
822
- # Validate if requested
823
- if validate:
824
- from ._validator import validate_on_save
825
-
826
- result = validate_on_save(fig, saved_yaml, mse_threshold=validate_mse_threshold)
827
- status = "PASSED" if result.valid else "FAILED"
828
- if verbose:
829
- print(
830
- f"Saved: {image_path} + {yaml_path} (Reproducible Validation: {status})"
831
- )
832
- if not result.valid:
833
- msg = f"Reproducibility validation failed (MSE={result.mse:.1f}): {result.message}"
834
- if validate_error_level == "error":
835
- raise ValueError(msg)
836
- elif validate_error_level == "warning":
837
- import warnings
838
-
839
- warnings.warn(msg, UserWarning)
840
- # "debug" level: silent, just return the result
841
- return image_path, yaml_path, result
842
-
843
- if verbose:
844
- print(f"Saved: {image_path} + {yaml_path}")
845
- return image_path, yaml_path, None
846
-
847
317
 
848
318
  def reproduce(
849
319
  path: Union[str, Path],
@@ -859,7 +329,7 @@ def reproduce(
859
329
  calls : list of str, optional
860
330
  If provided, only reproduce these specific call IDs.
861
331
  skip_decorations : bool
862
- If True, skip decoration calls (labels, legends, etc.).
332
+ If True, skip decoration calls.
863
333
 
864
334
  Returns
865
335
  -------
@@ -867,189 +337,42 @@ def reproduce(
867
337
  Reproduced figure.
868
338
  axes : Axes or list of Axes
869
339
  Reproduced axes.
870
-
871
- Examples
872
- --------
873
- >>> import figrecipe as ps
874
- >>> fig, ax = ps.reproduce('experiment.yaml')
875
- >>> plt.show()
876
-
877
- >>> # Reproduce only specific plots
878
- >>> fig, ax = ps.reproduce('experiment.yaml', calls=['scatter_001'])
879
340
  """
880
341
  return _reproduce(path, calls=calls, skip_decorations=skip_decorations)
881
342
 
882
343
 
883
344
  def info(path: Union[str, Path]) -> Dict[str, Any]:
884
- """Get information about a recipe without reproducing.
885
-
886
- Parameters
887
- ----------
888
- path : str or Path
889
- Path to .yaml recipe file.
890
-
891
- Returns
892
- -------
893
- dict
894
- Recipe information including figure ID, creation time,
895
- matplotlib version, size, and list of calls.
896
-
897
- Examples
898
- --------
899
- >>> import figrecipe as ps
900
- >>> recipe_info = ps.info('experiment.yaml')
901
- >>> print(f"Created: {recipe_info['created']}")
902
- >>> print(f"Calls: {len(recipe_info['calls'])}")
903
- """
345
+ """Get information about a recipe without reproducing."""
904
346
  return get_recipe_info(path)
905
347
 
906
348
 
907
- def load(path: Union[str, Path]) -> FigureRecord:
908
- """Load a recipe as a FigureRecord object.
349
+ def load_record(path: Union[str, Path]) -> FigureRecord:
350
+ """Load a recipe as a FigureRecord object (advanced use)."""
351
+ return load_recipe(path)
909
352
 
910
- Parameters
911
- ----------
912
- path : str or Path
913
- Path to .yaml recipe file.
914
353
 
915
- Returns
916
- -------
917
- FigureRecord
918
- The loaded figure record.
919
-
920
- Examples
921
- --------
922
- >>> import figrecipe as ps
923
- >>> record = ps.load('experiment.yaml')
924
- >>> # Modify the record
925
- >>> record.axes['ax_0_0'].calls[0].kwargs['color'] = 'blue'
926
- >>> # Reproduce with modifications
927
- >>> fig, ax = ps.reproduce_from_record(record)
928
- """
929
- return load_recipe(path)
354
+ # Alias for intuitive save/load symmetry
355
+ load = reproduce
930
356
 
931
357
 
932
358
  def extract_data(path: Union[str, Path]) -> Dict[str, Dict[str, Any]]:
933
359
  """Extract data arrays from a saved recipe.
934
360
 
935
- This function allows you to import/recover the data that was
936
- plotted in a figure from its recipe file.
937
-
938
- Parameters
939
- ----------
940
- path : str or Path
941
- Path to .yaml recipe file.
942
-
943
361
  Returns
944
362
  -------
945
363
  dict
946
364
  Nested dictionary: {call_id: {'x': array, 'y': array, ...}}
947
- Each call's data is stored under its ID with keys for each argument.
948
-
949
- Examples
950
- --------
951
- >>> import figrecipe as ps
952
- >>> import numpy as np
953
- >>>
954
- >>> # Create and save a figure
955
- >>> x = np.linspace(0, 10, 100)
956
- >>> y = np.sin(x)
957
- >>> fig, ax = ps.subplots()
958
- >>> ax.plot(x, y, id='sine_wave')
959
- >>> ps.save(fig, 'figure.yaml')
960
- >>>
961
- >>> # Later, extract the data
962
- >>> data = ps.extract_data('figure.yaml')
963
- >>> x_recovered = data['sine_wave']['x']
964
- >>> y_recovered = data['sine_wave']['y']
965
- >>> np.allclose(x, x_recovered)
966
- True
967
-
968
- Notes
969
- -----
970
- - Data is extracted from all plot calls (plot, scatter, bar, etc.)
971
- - For plot() calls: 'x' and 'y' contain the coordinates
972
- - For scatter(): 'x', 'y', and optionally 'c' (colors), 's' (sizes)
973
- - For bar(): 'x' (categories) and 'height' (values)
974
- - For hist(): 'x' (data array)
975
365
  """
976
- import numpy as np
366
+ from ._api._extract import DECORATION_FUNCS, extract_call_data
977
367
 
978
368
  record = load_recipe(path)
979
369
  result = {}
980
370
 
981
- # Decoration functions to skip
982
- decoration_funcs = {
983
- "set_xlabel",
984
- "set_ylabel",
985
- "set_title",
986
- "set_xlim",
987
- "set_ylim",
988
- "legend",
989
- "grid",
990
- "axhline",
991
- "axvline",
992
- "text",
993
- "annotate",
994
- }
995
-
996
371
  for ax_key, ax_record in record.axes.items():
997
372
  for call in ax_record.calls:
998
- # Skip decoration calls
999
- if call.function in decoration_funcs:
373
+ if call.function in DECORATION_FUNCS:
1000
374
  continue
1001
-
1002
- call_data = {}
1003
-
1004
- def to_array(data):
1005
- """Convert data to numpy array, handling YAML types."""
1006
- # Handle dict with 'data' key (serialized array format)
1007
- if isinstance(data, dict) or (hasattr(data, "keys") and "data" in data):
1008
- return np.array(data["data"])
1009
- if hasattr(data, "tolist"): # Already array-like
1010
- return np.array(data)
1011
- return np.array(
1012
- list(data)
1013
- if hasattr(data, "__iter__") and not isinstance(data, str)
1014
- else data
1015
- )
1016
-
1017
- # Extract positional arguments based on function type
1018
- if call.function in ("plot", "scatter", "fill_between"):
1019
- if len(call.args) >= 1:
1020
- call_data["x"] = to_array(call.args[0])
1021
- if len(call.args) >= 2:
1022
- call_data["y"] = to_array(call.args[1])
1023
-
1024
- elif call.function == "bar":
1025
- if len(call.args) >= 1:
1026
- call_data["x"] = to_array(call.args[0])
1027
- if len(call.args) >= 2:
1028
- call_data["height"] = to_array(call.args[1])
1029
-
1030
- elif call.function == "hist":
1031
- if len(call.args) >= 1:
1032
- call_data["x"] = to_array(call.args[0])
1033
-
1034
- elif call.function == "errorbar":
1035
- if len(call.args) >= 1:
1036
- call_data["x"] = to_array(call.args[0])
1037
- if len(call.args) >= 2:
1038
- call_data["y"] = to_array(call.args[1])
1039
-
1040
- # Extract relevant kwargs
1041
- for key in ("c", "s", "yerr", "xerr", "weights", "bins"):
1042
- if key in call.kwargs:
1043
- val = call.kwargs[key]
1044
- if (
1045
- isinstance(val, (list, tuple))
1046
- or hasattr(val, "__iter__")
1047
- and not isinstance(val, str)
1048
- ):
1049
- call_data[key] = to_array(val)
1050
- else:
1051
- call_data[key] = val
1052
-
375
+ call_data = extract_call_data(call)
1053
376
  if call_data:
1054
377
  result[call.id] = call_data
1055
378
 
@@ -1062,9 +385,6 @@ def validate(
1062
385
  ) -> ValidationResult:
1063
386
  """Validate that a saved recipe can reproduce its original figure.
1064
387
 
1065
- This is a standalone validation function for existing recipes.
1066
- For validation during save, use `ps.save(..., validate=True)`.
1067
-
1068
388
  Parameters
1069
389
  ----------
1070
390
  path : str or Path
@@ -1075,73 +395,11 @@ def validate(
1075
395
  Returns
1076
396
  -------
1077
397
  ValidationResult
1078
- Detailed comparison results including MSE, dimensions, etc.
1079
-
1080
- Examples
1081
- --------
1082
- >>> import figrecipe as ps
1083
- >>> result = ps.validate('experiment.yaml')
1084
- >>> print(result.summary())
1085
- >>> if result.valid:
1086
- ... print("Recipe is reproducible!")
1087
-
1088
- Notes
1089
- -----
1090
- This function reproduces the figure from the recipe and compares
1091
- the result to re-rendering the recipe. It cannot compare to the
1092
- original figure unless you use `ps.save(..., validate=True)` which
1093
- performs validation before closing the original figure.
398
+ Detailed comparison results.
1094
399
  """
1095
- # For standalone validation, we reproduce twice and compare
1096
- # (This validates the recipe is self-consistent)
1097
- import tempfile
1098
-
1099
- import numpy as np
1100
-
1101
- from ._reproducer import reproduce
1102
- from ._utils._image_diff import compare_images
1103
-
1104
- path = Path(path)
1105
-
1106
- with tempfile.TemporaryDirectory() as tmpdir:
1107
- tmpdir = Path(tmpdir)
1108
-
1109
- # Reproduce twice
1110
- fig1, _ = reproduce(path)
1111
- img1_path = tmpdir / "render1.png"
1112
- fig1.savefig(img1_path, dpi=150)
1113
-
1114
- fig2, _ = reproduce(path)
1115
- img2_path = tmpdir / "render2.png"
1116
- fig2.savefig(img2_path, dpi=150)
1117
-
1118
- # Compare
1119
- diff = compare_images(img1_path, img2_path)
1120
-
1121
- mse = diff["mse"]
1122
- if np.isnan(mse):
1123
- valid = False
1124
- message = f"Image dimensions differ: {diff['size1']} vs {diff['size2']}"
1125
- elif mse > mse_threshold:
1126
- valid = False
1127
- message = f"MSE ({mse:.2f}) exceeds threshold ({mse_threshold})"
1128
- else:
1129
- valid = True
1130
- message = "Recipe produces consistent output"
1131
-
1132
- return ValidationResult(
1133
- valid=valid,
1134
- mse=mse if not np.isnan(mse) else float("inf"),
1135
- psnr=diff["psnr"],
1136
- max_diff=diff["max_diff"]
1137
- if not np.isnan(diff["max_diff"])
1138
- else float("inf"),
1139
- size_original=diff["size1"],
1140
- size_reproduced=diff["size2"],
1141
- same_size=diff["same_size"],
1142
- file_size_diff=diff["file_size2"] - diff["file_size1"],
1143
- message=message,
1144
- )
400
+ from ._api._validate import validate_recipe
401
+
402
+ return validate_recipe(path, mse_threshold)
1145
403
 
1146
404
 
1147
405
  def crop(
@@ -1154,19 +412,14 @@ def crop(
1154
412
  ):
1155
413
  """Crop a figure image to its content area with a specified margin.
1156
414
 
1157
- Automatically detects background color (from corners) and crops to
1158
- content, leaving only the specified margin around it.
1159
-
1160
415
  Parameters
1161
416
  ----------
1162
417
  input_path : str or Path
1163
- Path to the input image (PNG, JPEG, etc.)
418
+ Path to the input image.
1164
419
  output_path : str or Path, optional
1165
- Path to save the cropped image. If None and overwrite=True,
1166
- overwrites the input. If None and overwrite=False, adds '_cropped' suffix.
420
+ Path to save the cropped image.
1167
421
  margin_mm : float, optional
1168
- Margin in millimeters to keep around content (default: 1.0mm).
1169
- Converted to pixels using image DPI (or 300 DPI if not available).
422
+ Margin in millimeters (default: 1.0mm).
1170
423
  margin_px : int, optional
1171
424
  Margin in pixels (overrides margin_mm if provided).
1172
425
  overwrite : bool, optional
@@ -1178,15 +431,6 @@ def crop(
1178
431
  -------
1179
432
  Path
1180
433
  Path to the saved cropped image.
1181
-
1182
- Examples
1183
- --------
1184
- >>> import figrecipe as fr
1185
- >>> fig, ax = fr.subplots(axes_width_mm=60, axes_height_mm=40)
1186
- >>> ax.plot([1, 2, 3], [1, 2, 3], id='line')
1187
- >>> fig.savefig("figure.png", dpi=300)
1188
- >>> fr.crop("figure.png", overwrite=True) # 1mm margin
1189
- >>> fr.crop("figure.png", margin_mm=2.0) # 2mm margin
1190
434
  """
1191
435
  from ._utils._crop import crop as _crop
1192
436
 
@@ -1194,165 +438,52 @@ def crop(
1194
438
 
1195
439
 
1196
440
  def edit(
1197
- source,
441
+ source=None,
1198
442
  style=None,
1199
443
  port: int = 5050,
444
+ host: str = "127.0.0.1",
1200
445
  open_browser: bool = True,
446
+ hot_reload: bool = False,
447
+ working_dir=None,
448
+ desktop: bool = False,
1201
449
  ):
1202
450
  """Launch interactive GUI editor for figure styling.
1203
451
 
1204
- Opens a browser-based editor that allows interactive adjustment of
1205
- figure styles using hitmap-based element selection.
1206
-
1207
452
  Parameters
1208
453
  ----------
1209
- source : RecordingFigure, str, or Path
1210
- Either a live RecordingFigure object or path to a .yaml recipe file.
454
+ source : RecordingFigure, str, Path, or None
455
+ Either a live RecordingFigure object, path to a .yaml recipe file,
456
+ or None to create a new blank figure.
1211
457
  style : str or dict, optional
1212
- Style preset name (e.g., 'SCITEX', 'SCITEX_DARK') or style dict.
1213
- If None, uses the currently loaded global style.
458
+ Style preset name or style dict.
1214
459
  port : int, optional
1215
- Flask server port (default: 5050). Auto-finds available port if occupied.
460
+ Flask server port (default: 5050).
461
+ host : str, optional
462
+ Host to bind Flask server (default: "127.0.0.1", use "0.0.0.0" for Docker).
1216
463
  open_browser : bool, optional
1217
464
  Whether to open browser automatically (default: True).
465
+ hot_reload : bool, optional
466
+ Enable hot reload (default: False).
467
+ working_dir : str or Path, optional
468
+ Working directory for file browser (default: directory containing source).
469
+ desktop : bool, optional
470
+ Launch as native desktop window using pywebview (default: False).
471
+ Requires: pip install figrecipe[desktop]
1218
472
 
1219
473
  Returns
1220
474
  -------
1221
475
  dict
1222
476
  Final style overrides after editing session.
1223
-
1224
- Examples
1225
- --------
1226
- Edit a live figure:
1227
-
1228
- >>> import figrecipe as fr
1229
- >>> fig, ax = fr.subplots()
1230
- >>> ax.plot([1, 2, 3], [1, 4, 9], id='quadratic')
1231
- >>> overrides = fr.edit(fig)
1232
-
1233
- Edit a saved recipe:
1234
-
1235
- >>> overrides = fr.edit('my_figure.yaml')
1236
-
1237
- With explicit style:
1238
-
1239
- >>> overrides = fr.edit(fig, style='SCITEX_DARK')
1240
-
1241
- Notes
1242
- -----
1243
- Requires Flask to be installed. Install with:
1244
- pip install figrecipe[editor]
1245
- or:
1246
- pip install flask pillow
1247
477
  """
1248
478
  from ._editor import edit as _edit
1249
479
 
1250
- return _edit(source, style=style, port=port, open_browser=open_browser)
1251
-
1252
-
1253
- def panel_label(
1254
- ax,
1255
- label: str,
1256
- loc: str = "upper left",
1257
- fontsize: Optional[float] = None,
1258
- fontweight: str = "bold",
1259
- offset: Tuple[float, float] = (-0.1, 1.05),
1260
- **kwargs,
1261
- ):
1262
- """Add a panel label (A, B, C, ...) to an axes.
1263
-
1264
- Panel labels are commonly used in multi-panel scientific figures to
1265
- identify individual subplots. This function places a label at the
1266
- specified location relative to the axes.
1267
-
1268
- Parameters
1269
- ----------
1270
- ax : Axes or RecordingAxes
1271
- The axes to label.
1272
- label : str
1273
- The label text (e.g., 'A', 'B', 'a)', '(1)').
1274
- loc : str, optional
1275
- Label location: 'upper left' (default), 'upper right',
1276
- 'lower left', 'lower right', or 'outside'.
1277
- fontsize : float, optional
1278
- Font size in points. If None, uses title font size from style or 10.
1279
- fontweight : str, optional
1280
- Font weight: 'bold' (default), 'normal', etc.
1281
- offset : tuple of float, optional
1282
- (x, y) offset in axes coordinates. Default (-0.1, 1.05) places
1283
- label slightly outside top-left corner.
1284
- **kwargs
1285
- Additional arguments passed to ax.text().
1286
-
1287
- Returns
1288
- -------
1289
- Text
1290
- The matplotlib Text object.
1291
-
1292
- Examples
1293
- --------
1294
- >>> import figrecipe as fr
1295
- >>> fig, axes = fr.subplots(nrows=2, ncols=2)
1296
- >>> for i, ax in enumerate(axes.flat):
1297
- ... fr.panel_label(ax, chr(65 + i)) # A, B, C, D
1298
-
1299
- >>> # Custom styling
1300
- >>> fr.panel_label(ax, 'a)', fontsize=12, fontweight='normal')
1301
-
1302
- >>> # Outside position (default)
1303
- >>> fr.panel_label(ax, 'A', loc='upper left')
1304
- """
1305
- # Get fontsize from style if available, otherwise default to 10pt
1306
- if fontsize is None:
1307
- try:
1308
- from .styles._style_loader import _STYLE_CACHE
1309
-
1310
- if _STYLE_CACHE is not None:
1311
- fontsize = getattr(
1312
- getattr(_STYLE_CACHE, "fonts", None), "panel_label_pt", 10
1313
- )
1314
- else:
1315
- fontsize = 10
1316
- except Exception:
1317
- fontsize = 10
1318
-
1319
- # Calculate position based on loc
1320
- if loc == "upper left":
1321
- x, y = offset
1322
- elif loc == "upper right":
1323
- x, y = 1.0 + abs(offset[0]), offset[1]
1324
- elif loc == "lower left":
1325
- x, y = offset[0], -abs(offset[1]) + 1.0
1326
- elif loc == "lower right":
1327
- x, y = 1.0 + abs(offset[0]), -abs(offset[1]) + 1.0
1328
- else:
1329
- x, y = offset
1330
-
1331
- # Default kwargs - use 'axes' as transform string (handled by reproducer)
1332
- text_kwargs = {
1333
- "fontsize": fontsize,
1334
- "fontweight": fontweight,
1335
- "transform": "axes", # Special string marker for axes coordinates
1336
- "va": "bottom",
1337
- "ha": "right" if "right" in loc else "left",
1338
- }
1339
- text_kwargs.update(kwargs)
1340
-
1341
- # Get the underlying matplotlib axes
1342
- mpl_ax = ax._ax if hasattr(ax, "_ax") else ax
1343
-
1344
- # For actual rendering, use the real transform
1345
- render_kwargs = text_kwargs.copy()
1346
- render_kwargs["transform"] = mpl_ax.transAxes
1347
-
1348
- # Record the call using recorder's method (handles args/kwargs processing)
1349
- if hasattr(ax, "_recorder") and hasattr(ax, "_position"):
1350
- ax._recorder.record_call(
1351
- ax_position=ax._position,
1352
- method_name="text",
1353
- args=(x, y, label),
1354
- kwargs=text_kwargs, # Contains transform: "axes"
1355
- )
1356
-
1357
- # Render directly on matplotlib axes with actual transform
1358
- return mpl_ax.text(x, y, label, **render_kwargs)
480
+ return _edit(
481
+ source,
482
+ style=style,
483
+ port=port,
484
+ host=host,
485
+ open_browser=open_browser,
486
+ hot_reload=hot_reload,
487
+ working_dir=working_dir,
488
+ desktop=desktop,
489
+ )