scitex 2.7.3__py3-none-any.whl → 2.10.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 (563) hide show
  1. scitex/__init__.py +15 -7
  2. scitex/__version__.py +1 -2
  3. scitex/_install_guide.py +250 -0
  4. scitex/_optional_deps.py +206 -39
  5. scitex/ai/_gen_ai/_Groq.py +2 -4
  6. scitex/ai/_gen_ai/_OpenAI.py +5 -2
  7. scitex/ai/_gen_ai/_Perplexity.py +20 -6
  8. scitex/audio/__init__.py +24 -15
  9. scitex/audio/_cross_process_lock.py +139 -0
  10. scitex/audio/_mcp_handlers.py +256 -0
  11. scitex/audio/_mcp_tool_schemas.py +203 -0
  12. scitex/audio/engines/elevenlabs_engine.py +5 -2
  13. scitex/audio/mcp_server.py +98 -457
  14. scitex/bridge/__init__.py +30 -19
  15. scitex/bridge/_figrecipe.py +245 -0
  16. scitex/bridge/_helpers.py +2 -1
  17. scitex/bridge/_plt_vis.py +23 -10
  18. scitex/bridge/_stats_plt.py +18 -5
  19. scitex/bridge/_stats_vis.py +16 -2
  20. scitex/browser/__init__.py +84 -44
  21. scitex/browser/automation/__init__.py +5 -1
  22. scitex/browser/core/BrowserMixin.py +17 -4
  23. scitex/browser/core/__init__.py +11 -2
  24. scitex/browser/remote/CaptchaHandler.py +1 -1
  25. scitex/browser/remote/ZenRowsAPIClient.py +1 -1
  26. scitex/capture/grid.py +487 -0
  27. scitex/capture/mcp_handlers.py +401 -0
  28. scitex/capture/mcp_tool_defs.py +192 -0
  29. scitex/capture/mcp_tools.py +241 -0
  30. scitex/capture/mcp_utils.py +30 -0
  31. scitex/cli/convert.py +421 -0
  32. scitex/cli/main.py +6 -4
  33. scitex/datetime/__init__.py +46 -0
  34. scitex/datetime/_linspace.py +100 -0
  35. scitex/datetime/_normalize_timestamp.py +306 -0
  36. scitex/db/_delete_duplicates.py +4 -4
  37. scitex/db/_sqlite3/_delete_duplicates.py +11 -2
  38. scitex/dev/plt/__init__.py +61 -62
  39. scitex/dev/plt/demo_plotters/__init__.py +0 -0
  40. scitex/dev/plt/demo_plotters/plot_mpl_axhline.py +28 -0
  41. scitex/dev/plt/demo_plotters/plot_mpl_axhspan.py +28 -0
  42. scitex/dev/plt/demo_plotters/plot_mpl_axvline.py +28 -0
  43. scitex/dev/plt/demo_plotters/plot_mpl_axvspan.py +28 -0
  44. scitex/dev/plt/demo_plotters/plot_mpl_bar.py +29 -0
  45. scitex/dev/plt/demo_plotters/plot_mpl_barh.py +29 -0
  46. scitex/dev/plt/demo_plotters/plot_mpl_boxplot.py +28 -0
  47. scitex/dev/plt/demo_plotters/plot_mpl_contour.py +31 -0
  48. scitex/dev/plt/demo_plotters/plot_mpl_contourf.py +31 -0
  49. scitex/dev/plt/demo_plotters/plot_mpl_errorbar.py +30 -0
  50. scitex/dev/plt/demo_plotters/plot_mpl_eventplot.py +28 -0
  51. scitex/dev/plt/demo_plotters/plot_mpl_fill.py +30 -0
  52. scitex/dev/plt/demo_plotters/plot_mpl_fill_between.py +31 -0
  53. scitex/dev/plt/demo_plotters/plot_mpl_hexbin.py +28 -0
  54. scitex/dev/plt/demo_plotters/plot_mpl_hist.py +28 -0
  55. scitex/dev/plt/demo_plotters/plot_mpl_hist2d.py +28 -0
  56. scitex/dev/plt/demo_plotters/plot_mpl_imshow.py +29 -0
  57. scitex/dev/plt/demo_plotters/plot_mpl_pcolormesh.py +31 -0
  58. scitex/dev/plt/demo_plotters/plot_mpl_pie.py +29 -0
  59. scitex/dev/plt/demo_plotters/plot_mpl_plot.py +29 -0
  60. scitex/dev/plt/demo_plotters/plot_mpl_quiver.py +31 -0
  61. scitex/dev/plt/demo_plotters/plot_mpl_scatter.py +28 -0
  62. scitex/dev/plt/demo_plotters/plot_mpl_stackplot.py +31 -0
  63. scitex/dev/plt/demo_plotters/plot_mpl_stem.py +29 -0
  64. scitex/dev/plt/demo_plotters/plot_mpl_step.py +29 -0
  65. scitex/dev/plt/demo_plotters/plot_mpl_violinplot.py +28 -0
  66. scitex/dev/plt/demo_plotters/plot_sns_barplot.py +29 -0
  67. scitex/dev/plt/demo_plotters/plot_sns_boxplot.py +29 -0
  68. scitex/dev/plt/demo_plotters/plot_sns_heatmap.py +28 -0
  69. scitex/dev/plt/demo_plotters/plot_sns_histplot.py +29 -0
  70. scitex/dev/plt/demo_plotters/plot_sns_kdeplot.py +29 -0
  71. scitex/dev/plt/demo_plotters/plot_sns_lineplot.py +31 -0
  72. scitex/dev/plt/demo_plotters/plot_sns_scatterplot.py +29 -0
  73. scitex/dev/plt/demo_plotters/plot_sns_stripplot.py +29 -0
  74. scitex/dev/plt/demo_plotters/plot_sns_swarmplot.py +29 -0
  75. scitex/dev/plt/demo_plotters/plot_sns_violinplot.py +29 -0
  76. scitex/dev/plt/demo_plotters/plot_stx_bar.py +29 -0
  77. scitex/dev/plt/demo_plotters/plot_stx_barh.py +29 -0
  78. scitex/dev/plt/demo_plotters/plot_stx_box.py +28 -0
  79. scitex/dev/plt/demo_plotters/plot_stx_boxplot.py +28 -0
  80. scitex/dev/plt/demo_plotters/plot_stx_conf_mat.py +28 -0
  81. scitex/dev/plt/demo_plotters/plot_stx_contour.py +31 -0
  82. scitex/dev/plt/demo_plotters/plot_stx_ecdf.py +28 -0
  83. scitex/dev/plt/demo_plotters/plot_stx_errorbar.py +30 -0
  84. scitex/dev/plt/demo_plotters/plot_stx_fill_between.py +31 -0
  85. scitex/dev/plt/demo_plotters/plot_stx_fillv.py +28 -0
  86. scitex/dev/plt/demo_plotters/plot_stx_heatmap.py +28 -0
  87. scitex/dev/plt/demo_plotters/plot_stx_image.py +28 -0
  88. scitex/dev/plt/demo_plotters/plot_stx_imshow.py +28 -0
  89. scitex/dev/plt/demo_plotters/plot_stx_joyplot.py +28 -0
  90. scitex/dev/plt/demo_plotters/plot_stx_kde.py +28 -0
  91. scitex/dev/plt/demo_plotters/plot_stx_line.py +28 -0
  92. scitex/dev/plt/demo_plotters/plot_stx_mean_ci.py +28 -0
  93. scitex/dev/plt/demo_plotters/plot_stx_mean_std.py +28 -0
  94. scitex/dev/plt/demo_plotters/plot_stx_median_iqr.py +28 -0
  95. scitex/dev/plt/demo_plotters/plot_stx_raster.py +28 -0
  96. scitex/dev/plt/demo_plotters/plot_stx_rectangle.py +28 -0
  97. scitex/dev/plt/demo_plotters/plot_stx_scatter.py +29 -0
  98. scitex/dev/plt/demo_plotters/plot_stx_shaded_line.py +29 -0
  99. scitex/dev/plt/demo_plotters/plot_stx_violin.py +28 -0
  100. scitex/dev/plt/demo_plotters/plot_stx_violinplot.py +28 -0
  101. scitex/dev/plt/mpl/get_dir_ax.py +46 -0
  102. scitex/dev/plt/mpl/get_signatures.py +176 -0
  103. scitex/dev/plt/mpl/get_signatures_details.py +522 -0
  104. scitex/dev/plt/plot_mpl_axhline.py +0 -0
  105. scitex/dev/plt/plot_mpl_axhspan.py +0 -0
  106. scitex/dev/plt/plot_mpl_axvline.py +0 -0
  107. scitex/dev/plt/plot_mpl_axvspan.py +0 -0
  108. scitex/dev/plt/plot_mpl_bar.py +0 -0
  109. scitex/dev/plt/plot_mpl_barh.py +0 -0
  110. scitex/dev/plt/plot_mpl_boxplot.py +0 -0
  111. scitex/dev/plt/plot_mpl_contour.py +0 -0
  112. scitex/dev/plt/plot_mpl_contourf.py +0 -0
  113. scitex/dev/plt/plot_mpl_errorbar.py +0 -0
  114. scitex/dev/plt/plot_mpl_eventplot.py +0 -0
  115. scitex/dev/plt/plot_mpl_fill.py +0 -0
  116. scitex/dev/plt/plot_mpl_fill_between.py +0 -0
  117. scitex/dev/plt/plot_mpl_hexbin.py +0 -0
  118. scitex/dev/plt/plot_mpl_hist.py +0 -0
  119. scitex/dev/plt/plot_mpl_hist2d.py +0 -0
  120. scitex/dev/plt/plot_mpl_imshow.py +0 -0
  121. scitex/dev/plt/plot_mpl_pcolormesh.py +0 -0
  122. scitex/dev/plt/plot_mpl_pie.py +0 -0
  123. scitex/dev/plt/plot_mpl_plot.py +0 -0
  124. scitex/dev/plt/plot_mpl_quiver.py +0 -0
  125. scitex/dev/plt/plot_mpl_scatter.py +0 -0
  126. scitex/dev/plt/plot_mpl_stackplot.py +0 -0
  127. scitex/dev/plt/plot_mpl_stem.py +0 -0
  128. scitex/dev/plt/plot_mpl_step.py +0 -0
  129. scitex/dev/plt/plot_mpl_violinplot.py +0 -0
  130. scitex/dev/plt/plot_sns_barplot.py +0 -0
  131. scitex/dev/plt/plot_sns_boxplot.py +0 -0
  132. scitex/dev/plt/plot_sns_heatmap.py +0 -0
  133. scitex/dev/plt/plot_sns_histplot.py +0 -0
  134. scitex/dev/plt/plot_sns_kdeplot.py +0 -0
  135. scitex/dev/plt/plot_sns_lineplot.py +0 -0
  136. scitex/dev/plt/plot_sns_scatterplot.py +0 -0
  137. scitex/dev/plt/plot_sns_stripplot.py +0 -0
  138. scitex/dev/plt/plot_sns_swarmplot.py +0 -0
  139. scitex/dev/plt/plot_sns_violinplot.py +0 -0
  140. scitex/dev/plt/plot_stx_bar.py +0 -0
  141. scitex/dev/plt/plot_stx_barh.py +0 -0
  142. scitex/dev/plt/plot_stx_box.py +0 -0
  143. scitex/dev/plt/plot_stx_boxplot.py +0 -0
  144. scitex/dev/plt/plot_stx_conf_mat.py +0 -0
  145. scitex/dev/plt/plot_stx_contour.py +0 -0
  146. scitex/dev/plt/plot_stx_ecdf.py +0 -0
  147. scitex/dev/plt/plot_stx_errorbar.py +0 -0
  148. scitex/dev/plt/plot_stx_fill_between.py +0 -0
  149. scitex/dev/plt/plot_stx_fillv.py +0 -0
  150. scitex/dev/plt/plot_stx_heatmap.py +0 -0
  151. scitex/dev/plt/plot_stx_image.py +0 -0
  152. scitex/dev/plt/plot_stx_imshow.py +0 -0
  153. scitex/dev/plt/plot_stx_joyplot.py +0 -0
  154. scitex/dev/plt/plot_stx_kde.py +0 -0
  155. scitex/dev/plt/plot_stx_line.py +0 -0
  156. scitex/dev/plt/plot_stx_mean_ci.py +0 -0
  157. scitex/dev/plt/plot_stx_mean_std.py +0 -0
  158. scitex/dev/plt/plot_stx_median_iqr.py +0 -0
  159. scitex/dev/plt/plot_stx_raster.py +0 -0
  160. scitex/dev/plt/plot_stx_rectangle.py +0 -0
  161. scitex/dev/plt/plot_stx_scatter.py +0 -0
  162. scitex/dev/plt/plot_stx_shaded_line.py +0 -0
  163. scitex/dev/plt/plot_stx_violin.py +0 -0
  164. scitex/dev/plt/plot_stx_violinplot.py +0 -0
  165. scitex/diagram/README.md +197 -0
  166. scitex/diagram/__init__.py +48 -0
  167. scitex/diagram/_compile.py +312 -0
  168. scitex/diagram/_diagram.py +355 -0
  169. scitex/diagram/_presets.py +173 -0
  170. scitex/diagram/_schema.py +182 -0
  171. scitex/diagram/_split.py +278 -0
  172. scitex/dict/_pop_keys.py +1 -7
  173. scitex/dsp/__init__.py +15 -10
  174. scitex/dsp/add_noise.py +5 -2
  175. scitex/dsp/example.py +35 -22
  176. scitex/dsp/filt.py +8 -3
  177. scitex/dsp/reference.py +3 -2
  178. scitex/dsp/utils/__init__.py +2 -1
  179. scitex/dsp/utils/_differential_bandpass_filters.py +14 -4
  180. scitex/dt/__init__.py +39 -2
  181. scitex/errors.py +82 -521
  182. scitex/fig/__init__.py +4 -4
  183. scitex/fig/editor/__init__.py +5 -2
  184. scitex/fig/editor/_dearpygui_editor.py +1 -1
  185. scitex/fig/editor/_mpl_editor.py +1 -1
  186. scitex/fig/editor/_qt_editor.py +1 -1
  187. scitex/fig/editor/_tkinter_editor.py +1 -1
  188. scitex/fig/editor/edit/__init__.py +50 -0
  189. scitex/fig/editor/edit/backend_detector.py +109 -0
  190. scitex/fig/editor/edit/bundle_resolver.py +240 -0
  191. scitex/fig/editor/edit/editor_launcher.py +239 -0
  192. scitex/fig/editor/edit/manual_handler.py +53 -0
  193. scitex/fig/editor/edit/panel_loader.py +232 -0
  194. scitex/fig/editor/edit/path_resolver.py +67 -0
  195. scitex/fig/editor/flask_editor/_bbox.py +23 -0
  196. scitex/fig/editor/flask_editor/_core.py +908 -103
  197. scitex/fig/editor/flask_editor/_renderer.py +74 -0
  198. scitex/fig/editor/flask_editor/static/css/base/reset.css +41 -0
  199. scitex/fig/editor/flask_editor/static/css/base/typography.css +16 -0
  200. scitex/fig/editor/flask_editor/static/css/base/variables.css +85 -0
  201. scitex/fig/editor/flask_editor/static/css/components/buttons.css +217 -0
  202. scitex/fig/editor/flask_editor/static/css/components/context-menu.css +93 -0
  203. scitex/fig/editor/flask_editor/static/css/components/dropdown.css +57 -0
  204. scitex/fig/editor/flask_editor/static/css/components/forms.css +112 -0
  205. scitex/fig/editor/flask_editor/static/css/components/modal.css +59 -0
  206. scitex/fig/editor/flask_editor/static/css/components/sections.css +212 -0
  207. scitex/fig/editor/flask_editor/static/css/features/canvas.css +176 -0
  208. scitex/fig/editor/flask_editor/static/css/features/element-inspector.css +190 -0
  209. scitex/fig/editor/flask_editor/static/css/features/loading.css +59 -0
  210. scitex/fig/editor/flask_editor/static/css/features/overlay.css +45 -0
  211. scitex/fig/editor/flask_editor/static/css/features/panel-grid.css +95 -0
  212. scitex/fig/editor/flask_editor/static/css/features/selection.css +101 -0
  213. scitex/fig/editor/flask_editor/static/css/features/statistics.css +138 -0
  214. scitex/fig/editor/flask_editor/static/css/index.css +31 -0
  215. scitex/fig/editor/flask_editor/static/css/layout/container.css +7 -0
  216. scitex/fig/editor/flask_editor/static/css/layout/controls.css +56 -0
  217. scitex/fig/editor/flask_editor/static/css/layout/preview.css +78 -0
  218. scitex/fig/editor/flask_editor/static/js/alignment/axis.js +314 -0
  219. scitex/fig/editor/flask_editor/static/js/alignment/basic.js +107 -0
  220. scitex/fig/editor/flask_editor/static/js/alignment/distribute.js +54 -0
  221. scitex/fig/editor/flask_editor/static/js/canvas/canvas.js +172 -0
  222. scitex/fig/editor/flask_editor/static/js/canvas/dragging.js +258 -0
  223. scitex/fig/editor/flask_editor/static/js/canvas/resize.js +48 -0
  224. scitex/fig/editor/flask_editor/static/js/canvas/selection.js +71 -0
  225. scitex/fig/editor/flask_editor/static/js/core/api.js +288 -0
  226. scitex/fig/editor/flask_editor/static/js/core/state.js +143 -0
  227. scitex/fig/editor/flask_editor/static/js/core/utils.js +245 -0
  228. scitex/fig/editor/flask_editor/static/js/dev/element-inspector.js +992 -0
  229. scitex/fig/editor/flask_editor/static/js/editor/bbox.js +339 -0
  230. scitex/fig/editor/flask_editor/static/js/editor/element-drag.js +286 -0
  231. scitex/fig/editor/flask_editor/static/js/editor/overlay.js +371 -0
  232. scitex/fig/editor/flask_editor/static/js/editor/preview.js +293 -0
  233. scitex/fig/editor/flask_editor/static/js/main.js +426 -0
  234. scitex/fig/editor/flask_editor/static/js/shortcuts/context-menu.js +152 -0
  235. scitex/fig/editor/flask_editor/static/js/shortcuts/keyboard.js +265 -0
  236. scitex/fig/editor/flask_editor/static/js/ui/controls.js +184 -0
  237. scitex/fig/editor/flask_editor/static/js/ui/download.js +57 -0
  238. scitex/fig/editor/flask_editor/static/js/ui/help.js +100 -0
  239. scitex/fig/editor/flask_editor/static/js/ui/theme.js +34 -0
  240. scitex/fig/editor/flask_editor/templates/__init__.py +95 -5
  241. scitex/fig/editor/flask_editor/templates/_html.py +27 -9
  242. scitex/fig/editor/flask_editor/templates/_scripts.py +1928 -131
  243. scitex/fig/editor/flask_editor/templates/_styles.py +363 -51
  244. scitex/fig/io/_bundle.py +104 -19
  245. scitex/fts/README.md +262 -0
  246. scitex/fts/TODO.md +66 -0
  247. scitex/fts/__init__.py +90 -0
  248. scitex/fts/_bundle/README_IN_BUNDLE.md +102 -0
  249. scitex/fts/_bundle/_FTS.py +657 -0
  250. scitex/fts/_bundle/__init__.py +38 -0
  251. scitex/fts/_bundle/_children.py +216 -0
  252. scitex/fts/_bundle/_conversion/__init__.py +15 -0
  253. scitex/fts/_bundle/_conversion/_bundle2dict.py +44 -0
  254. scitex/fts/_bundle/_conversion/_dict2bundle.py +50 -0
  255. scitex/fts/_bundle/_dataclasses/_Axes.py +57 -0
  256. scitex/fts/_bundle/_dataclasses/_BBox.py +54 -0
  257. scitex/fts/_bundle/_dataclasses/_ColumnDef.py +72 -0
  258. scitex/fts/_bundle/_dataclasses/_DataFormat.py +40 -0
  259. scitex/fts/_bundle/_dataclasses/_DataInfo.py +135 -0
  260. scitex/fts/_bundle/_dataclasses/_DataSource.py +44 -0
  261. scitex/fts/_bundle/_dataclasses/_Node.py +319 -0
  262. scitex/fts/_bundle/_dataclasses/_NodeRefs.py +45 -0
  263. scitex/fts/_bundle/_dataclasses/_SizeMM.py +38 -0
  264. scitex/fts/_bundle/_dataclasses/__init__.py +35 -0
  265. scitex/fts/_bundle/_extractors/__init__.py +32 -0
  266. scitex/fts/_bundle/_extractors/_extract_bar.py +131 -0
  267. scitex/fts/_bundle/_extractors/_extract_line.py +71 -0
  268. scitex/fts/_bundle/_extractors/_extract_scatter.py +79 -0
  269. scitex/fts/_bundle/_loader.py +134 -0
  270. scitex/fts/_bundle/_mpl_helpers.py +389 -0
  271. scitex/fts/_bundle/_saver.py +269 -0
  272. scitex/fts/_bundle/_storage.py +200 -0
  273. scitex/fts/_bundle/_utils/__init__.py +55 -0
  274. scitex/fts/_bundle/_utils/_const.py +26 -0
  275. scitex/fts/_bundle/_utils/_errors.py +73 -0
  276. scitex/fts/_bundle/_utils/_generate.py +21 -0
  277. scitex/fts/_bundle/_utils/_types.py +76 -0
  278. scitex/fts/_bundle/_validation.py +434 -0
  279. scitex/fts/_bundle/_zipbundle.py +165 -0
  280. scitex/fts/_fig/__init__.py +22 -0
  281. scitex/fts/_fig/_backend/__init__.py +53 -0
  282. scitex/fts/_fig/_backend/_export.py +165 -0
  283. scitex/fts/_fig/_backend/_parser.py +188 -0
  284. scitex/fts/_fig/_backend/_render.py +538 -0
  285. scitex/fts/_fig/_composite.py +345 -0
  286. scitex/fts/_fig/_dataclasses/_ChannelEncoding.py +46 -0
  287. scitex/fts/_fig/_dataclasses/_Encoding.py +82 -0
  288. scitex/fts/_fig/_dataclasses/_Theme.py +441 -0
  289. scitex/fts/_fig/_dataclasses/_TraceEncoding.py +52 -0
  290. scitex/fts/_fig/_dataclasses/__init__.py +47 -0
  291. scitex/fts/_fig/_editor/__init__.py +14 -0
  292. scitex/fts/_fig/_editor/_cui/__init__.py +33 -0
  293. scitex/fts/_fig/_editor/_cui/_backend_detector.py +39 -0
  294. scitex/fts/_fig/_editor/_cui/_bundle_resolver.py +366 -0
  295. scitex/fts/_fig/_editor/_cui/_editor_launcher.py +175 -0
  296. scitex/fts/_fig/_editor/_cui/_manual_handler.py +52 -0
  297. scitex/fts/_fig/_editor/_cui/_panel_loader.py +246 -0
  298. scitex/fts/_fig/_editor/_cui/_path_resolver.py +66 -0
  299. scitex/fts/_fig/_editor/_defaults.py +300 -0
  300. scitex/fts/_fig/_editor/_gui/__init__.py +11 -0
  301. scitex/fts/_fig/_editor/_gui/_flask_editor/__init__.py +20 -0
  302. scitex/fts/_fig/_editor/_gui/_flask_editor/_bbox.py +1339 -0
  303. scitex/fts/_fig/_editor/_gui/_flask_editor/_core.py +1688 -0
  304. scitex/fts/_fig/_editor/_gui/_flask_editor/_plotter.py +664 -0
  305. scitex/fts/_fig/_editor/_gui/_flask_editor/_renderer.py +853 -0
  306. scitex/fts/_fig/_editor/_gui/_flask_editor/_utils.py +79 -0
  307. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/base/reset.css +41 -0
  308. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/base/typography.css +16 -0
  309. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/base/variables.css +85 -0
  310. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/buttons.css +217 -0
  311. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/context-menu.css +93 -0
  312. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/dropdown.css +57 -0
  313. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/forms.css +112 -0
  314. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/modal.css +59 -0
  315. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/sections.css +212 -0
  316. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/canvas.css +176 -0
  317. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/element-inspector.css +190 -0
  318. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/loading.css +59 -0
  319. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/overlay.css +45 -0
  320. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/panel-grid.css +95 -0
  321. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/selection.css +101 -0
  322. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/statistics.css +138 -0
  323. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/index.css +31 -0
  324. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/layout/container.css +7 -0
  325. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/layout/controls.css +56 -0
  326. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/layout/preview.css +78 -0
  327. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/alignment/axis.js +314 -0
  328. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/alignment/basic.js +107 -0
  329. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/alignment/distribute.js +54 -0
  330. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/canvas/canvas.js +172 -0
  331. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/canvas/dragging.js +258 -0
  332. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/canvas/resize.js +48 -0
  333. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/canvas/selection.js +71 -0
  334. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/core/api.js +288 -0
  335. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/core/state.js +143 -0
  336. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/core/utils.js +245 -0
  337. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/dev/element-inspector.js +992 -0
  338. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/editor/bbox.js +339 -0
  339. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/editor/element-drag.js +286 -0
  340. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/editor/overlay.js +371 -0
  341. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/editor/preview.js +293 -0
  342. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/main.js +426 -0
  343. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/shortcuts/context-menu.js +152 -0
  344. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/shortcuts/keyboard.js +265 -0
  345. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/ui/controls.js +184 -0
  346. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/ui/download.js +57 -0
  347. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/ui/help.js +100 -0
  348. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/ui/theme.js +34 -0
  349. scitex/fts/_fig/_editor/_gui/_flask_editor/templates/__init__.py +124 -0
  350. scitex/fts/_fig/_editor/_gui/_flask_editor/templates/_html.py +851 -0
  351. scitex/fts/_fig/_editor/_gui/_flask_editor/templates/_scripts.py +4932 -0
  352. scitex/fts/_fig/_editor/_gui/_flask_editor/templates/_styles.py +1657 -0
  353. scitex/fts/_fig/_editor/_gui/_flask_editor.py +36 -0
  354. scitex/fts/_fig/_models/_Annotations.py +115 -0
  355. scitex/fts/_fig/_models/_Axes.py +152 -0
  356. scitex/fts/_fig/_models/_Figure.py +138 -0
  357. scitex/fts/_fig/_models/_Guides.py +104 -0
  358. scitex/fts/_fig/_models/_Plot.py +123 -0
  359. scitex/fts/_fig/_models/_Styles.py +245 -0
  360. scitex/fts/_fig/_models/__init__.py +80 -0
  361. scitex/fts/_fig/_models/_plot_types/__init__.py +156 -0
  362. scitex/fts/_fig/_models/_plot_types/_bar.py +43 -0
  363. scitex/fts/_fig/_models/_plot_types/_box.py +38 -0
  364. scitex/fts/_fig/_models/_plot_types/_distribution.py +36 -0
  365. scitex/fts/_fig/_models/_plot_types/_errorbar.py +60 -0
  366. scitex/fts/_fig/_models/_plot_types/_histogram.py +30 -0
  367. scitex/fts/_fig/_models/_plot_types/_image.py +61 -0
  368. scitex/fts/_fig/_models/_plot_types/_line.py +57 -0
  369. scitex/fts/_fig/_models/_plot_types/_scatter.py +30 -0
  370. scitex/fts/_fig/_models/_plot_types/_seaborn.py +121 -0
  371. scitex/fts/_fig/_models/_plot_types/_violin.py +36 -0
  372. scitex/fts/_fig/_utils/__init__.py +129 -0
  373. scitex/fts/_fig/_utils/_auto_layout.py +127 -0
  374. scitex/fts/_fig/_utils/_calc_bounds.py +111 -0
  375. scitex/fts/_fig/_utils/_const_sizes.py +48 -0
  376. scitex/fts/_fig/_utils/_convert_coords.py +77 -0
  377. scitex/fts/_fig/_utils/_get_template.py +178 -0
  378. scitex/fts/_fig/_utils/_normalize.py +73 -0
  379. scitex/fts/_fig/_utils/_plot_layout.py +397 -0
  380. scitex/fts/_fig/_utils/_validate.py +197 -0
  381. scitex/fts/_kinds/__init__.py +45 -0
  382. scitex/fts/_kinds/_figure/__init__.py +19 -0
  383. scitex/fts/_kinds/_figure/_composite.py +345 -0
  384. scitex/fts/_kinds/_plot/__init__.py +25 -0
  385. scitex/fts/_kinds/_plot/_backend/__init__.py +53 -0
  386. scitex/fts/_kinds/_plot/_backend/_export.py +165 -0
  387. scitex/fts/_kinds/_plot/_backend/_parser.py +188 -0
  388. scitex/fts/_kinds/_plot/_backend/_render.py +538 -0
  389. scitex/fts/_kinds/_plot/_dataclasses/_ChannelEncoding.py +46 -0
  390. scitex/fts/_kinds/_plot/_dataclasses/_Encoding.py +82 -0
  391. scitex/fts/_kinds/_plot/_dataclasses/_Theme.py +441 -0
  392. scitex/fts/_kinds/_plot/_dataclasses/_TraceEncoding.py +52 -0
  393. scitex/fts/_kinds/_plot/_dataclasses/__init__.py +47 -0
  394. scitex/fts/_kinds/_plot/_models/_Annotations.py +115 -0
  395. scitex/fts/_kinds/_plot/_models/_Axes.py +152 -0
  396. scitex/fts/_kinds/_plot/_models/_Figure.py +138 -0
  397. scitex/fts/_kinds/_plot/_models/_Guides.py +104 -0
  398. scitex/fts/_kinds/_plot/_models/_Plot.py +123 -0
  399. scitex/fts/_kinds/_plot/_models/_Styles.py +245 -0
  400. scitex/fts/_kinds/_plot/_models/__init__.py +80 -0
  401. scitex/fts/_kinds/_plot/_models/_plot_types/__init__.py +156 -0
  402. scitex/fts/_kinds/_plot/_models/_plot_types/_bar.py +43 -0
  403. scitex/fts/_kinds/_plot/_models/_plot_types/_box.py +38 -0
  404. scitex/fts/_kinds/_plot/_models/_plot_types/_distribution.py +36 -0
  405. scitex/fts/_kinds/_plot/_models/_plot_types/_errorbar.py +60 -0
  406. scitex/fts/_kinds/_plot/_models/_plot_types/_histogram.py +30 -0
  407. scitex/fts/_kinds/_plot/_models/_plot_types/_image.py +61 -0
  408. scitex/fts/_kinds/_plot/_models/_plot_types/_line.py +57 -0
  409. scitex/fts/_kinds/_plot/_models/_plot_types/_scatter.py +30 -0
  410. scitex/fts/_kinds/_plot/_models/_plot_types/_seaborn.py +121 -0
  411. scitex/fts/_kinds/_plot/_models/_plot_types/_violin.py +36 -0
  412. scitex/fts/_kinds/_plot/_utils/__init__.py +129 -0
  413. scitex/fts/_kinds/_plot/_utils/_auto_layout.py +127 -0
  414. scitex/fts/_kinds/_plot/_utils/_calc_bounds.py +111 -0
  415. scitex/fts/_kinds/_plot/_utils/_const_sizes.py +48 -0
  416. scitex/fts/_kinds/_plot/_utils/_convert_coords.py +77 -0
  417. scitex/fts/_kinds/_plot/_utils/_get_template.py +178 -0
  418. scitex/fts/_kinds/_plot/_utils/_normalize.py +73 -0
  419. scitex/fts/_kinds/_plot/_utils/_plot_layout.py +397 -0
  420. scitex/fts/_kinds/_plot/_utils/_validate.py +197 -0
  421. scitex/fts/_kinds/_shape/__init__.py +141 -0
  422. scitex/fts/_kinds/_stats/__init__.py +56 -0
  423. scitex/fts/_kinds/_stats/_dataclasses/_Stats.py +423 -0
  424. scitex/fts/_kinds/_stats/_dataclasses/__init__.py +48 -0
  425. scitex/fts/_kinds/_table/__init__.py +72 -0
  426. scitex/fts/_kinds/_table/_latex/__init__.py +93 -0
  427. scitex/fts/_kinds/_table/_latex/_editor/__init__.py +11 -0
  428. scitex/fts/_kinds/_table/_latex/_editor/_app.py +725 -0
  429. scitex/fts/_kinds/_table/_latex/_export.py +279 -0
  430. scitex/fts/_kinds/_table/_latex/_figure_exporter.py +153 -0
  431. scitex/fts/_kinds/_table/_latex/_stats_formatter.py +274 -0
  432. scitex/fts/_kinds/_table/_latex/_table_exporter.py +362 -0
  433. scitex/fts/_kinds/_table/_latex/_utils.py +369 -0
  434. scitex/fts/_kinds/_table/_latex/_validator.py +445 -0
  435. scitex/fts/_kinds/_text/__init__.py +77 -0
  436. scitex/fts/_schemas/data_info.schema.json +75 -0
  437. scitex/fts/_schemas/encoding.schema.json +90 -0
  438. scitex/fts/_schemas/node.schema.json +145 -0
  439. scitex/fts/_schemas/render_manifest.schema.json +62 -0
  440. scitex/fts/_schemas/stats.schema.json +132 -0
  441. scitex/fts/_schemas/theme.schema.json +141 -0
  442. scitex/fts/_stats/__init__.py +48 -0
  443. scitex/fts/_stats/_dataclasses/_Stats.py +423 -0
  444. scitex/fts/_stats/_dataclasses/__init__.py +48 -0
  445. scitex/fts/_tables/__init__.py +65 -0
  446. scitex/fts/_tables/_latex/__init__.py +93 -0
  447. scitex/fts/_tables/_latex/_editor/__init__.py +11 -0
  448. scitex/fts/_tables/_latex/_editor/_app.py +725 -0
  449. scitex/fts/_tables/_latex/_export.py +279 -0
  450. scitex/fts/_tables/_latex/_figure_exporter.py +153 -0
  451. scitex/fts/_tables/_latex/_stats_formatter.py +274 -0
  452. scitex/fts/_tables/_latex/_table_exporter.py +362 -0
  453. scitex/fts/_tables/_latex/_utils.py +369 -0
  454. scitex/fts/_tables/_latex/_validator.py +445 -0
  455. scitex/gen/__init__.py +66 -25
  456. scitex/gen/misc.py +28 -0
  457. scitex/io/__init__.py +47 -20
  458. scitex/io/_load.py +87 -36
  459. scitex/io/_load_modules/__init__.py +10 -7
  460. scitex/io/_load_modules/_pandas.py +6 -1
  461. scitex/io/_save.py +299 -1556
  462. scitex/io/_save_modules/__init__.py +76 -19
  463. scitex/io/_save_modules/_figure_utils.py +90 -0
  464. scitex/io/_save_modules/_image_csv.py +497 -0
  465. scitex/io/_save_modules/_legends.py +91 -0
  466. scitex/io/_save_modules/_pltz_bundle.py +356 -0
  467. scitex/io/_save_modules/_pltz_stx.py +536 -0
  468. scitex/io/_save_modules/_stx_bundle.py +104 -0
  469. scitex/io/_save_modules/_symlink.py +96 -0
  470. scitex/io/_save_modules/_yaml.py +1 -1
  471. scitex/io/_save_modules/_zarr.py +64 -18
  472. scitex/io/bundle/README.md +212 -0
  473. scitex/io/bundle/__init__.py +110 -0
  474. scitex/io/{_bundle.py → bundle/_core.py} +219 -89
  475. scitex/io/bundle/_nested.py +713 -0
  476. scitex/io/bundle/_types.py +74 -0
  477. scitex/io/bundle/_zip.py +487 -0
  478. scitex/io/utils/h5_to_zarr.py +1 -1
  479. scitex/logging/__init__.py +108 -13
  480. scitex/logging/_errors.py +508 -0
  481. scitex/logging/_formatters.py +30 -6
  482. scitex/logging/_warnings.py +261 -0
  483. scitex/plt/__init__.py +4 -1
  484. scitex/plt/_figrecipe.py +236 -0
  485. scitex/plt/_subplots/_AxisWrapper.py +6 -0
  486. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/__init__.py +0 -0
  487. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_labels.py +0 -0
  488. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_metadata.py +0 -0
  489. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_visual.py +0 -0
  490. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/__init__.py +0 -0
  491. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_base.py +0 -0
  492. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_scientific.py +0 -0
  493. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_statistical.py +0 -0
  494. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_stx_aliases.py +0 -0
  495. scitex/plt/_subplots/_AxisWrapperMixins/_RawMatplotlibMixin.py +0 -0
  496. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/__init__.py +0 -0
  497. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/_base.py +0 -0
  498. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/_wrappers.py +0 -0
  499. scitex/plt/_subplots/_AxisWrapperMixins/_UnitAwareMixin.py +112 -1
  500. scitex/plt/_subplots/_FigWrapper.py +15 -0
  501. scitex/plt/_subplots/_SubplotsWrapper.py +125 -489
  502. scitex/plt/_subplots/_export_as_csv.py +11 -0
  503. scitex/plt/_subplots/_export_as_csv_formatters/__init__.py +2 -0
  504. scitex/plt/_subplots/_export_as_csv_formatters/_format_pcolormesh.py +66 -0
  505. scitex/plt/_subplots/_export_as_csv_formatters/_format_stackplot.py +62 -0
  506. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_bar.py +0 -0
  507. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_barh.py +0 -0
  508. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_errorbar.py +0 -0
  509. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_scatter.py +0 -0
  510. scitex/plt/_subplots/_export_as_csv_formatters/test_formatters.py +208 -0
  511. scitex/plt/_subplots/_fonts.py +71 -0
  512. scitex/plt/_subplots/_mm_layout.py +282 -0
  513. scitex/plt/gallery/__init__.py +99 -2
  514. scitex/plt/io/_layered_bundle.py +0 -0
  515. scitex/plt/styles/_plot_postprocess.py +3 -1
  516. scitex/plt/utils/_configure_mpl.py +16 -19
  517. scitex/repro/_RandomStateManager.py +13 -8
  518. scitex/resource/__init__.py +19 -1
  519. scitex/resource/_utils/_get_env_info.py +13 -25
  520. scitex/schema/__init__.py +149 -160
  521. scitex/schema/_encoding.py +273 -0
  522. scitex/schema/_figure_elements.py +406 -0
  523. scitex/schema/_plot.py +0 -0
  524. scitex/schema/_theme.py +360 -0
  525. scitex/schema/_validation.py +0 -98
  526. scitex/scholar/__init__.py +56 -14
  527. scitex/scholar/auth/ScholarAuthManager.py +1 -1
  528. scitex/scholar/auth/__init__.py +11 -2
  529. scitex/scholar/auth/providers/BaseAuthenticator.py +1 -1
  530. scitex/scholar/auth/providers/EZProxyAuthenticator.py +1 -1
  531. scitex/scholar/auth/providers/OpenAthensAuthenticator.py +1 -1
  532. scitex/scholar/auth/providers/ShibbolethAuthenticator.py +1 -1
  533. scitex/scholar/config/ScholarConfig.py +1 -1
  534. scitex/scholar/core/Scholar.py +1 -1
  535. scitex/session/_decorator.py +18 -16
  536. scitex/session/_lifecycle.py +9 -11
  537. scitex/session/template.py +9 -8
  538. scitex/sh/test_sh.py +72 -0
  539. scitex/sh/test_sh_simple.py +61 -0
  540. scitex/stats/__init__.py +221 -97
  541. scitex/stats/_schema.py +21 -22
  542. scitex/stats/descriptive/_circular.py +212 -351
  543. scitex/stats/descriptive/_describe.py +81 -132
  544. scitex/stats/descriptive/_nan.py +205 -433
  545. scitex/stats/descriptive/_real.py +127 -141
  546. scitex/str/_format_plot_text.py +5 -5
  547. scitex/str/_latex.py +26 -84
  548. scitex/str/_latex_fallback.py +53 -47
  549. scitex/web/_search_pubmed.py +5 -4
  550. scitex/writer/tests/test_diff_between.py +451 -0
  551. scitex/writer/tests/test_document_section.py +311 -0
  552. scitex/writer/tests/test_document_workflow.py +393 -0
  553. scitex/writer/tests/test_writer.py +361 -0
  554. scitex/writer/tests/test_writer_integration.py +303 -0
  555. {scitex-2.7.3.dist-info → scitex-2.10.0.dist-info}/METADATA +364 -181
  556. {scitex-2.7.3.dist-info → scitex-2.10.0.dist-info}/RECORD +479 -108
  557. scitex/fig/editor/_edit.py +0 -751
  558. scitex/scholar/docs/to_claude/guidelines/examples/mgmt/ARCHITECTURE_EXAMPLE.md +0 -905
  559. scitex/scholar/docs/to_claude/guidelines/examples/mgmt/BULLETIN_BOARD_EXAMPLE.md +0 -99
  560. scitex/scholar/docs/to_claude/guidelines/examples/mgmt/PROJECT_DESCRIPTION_EXAMPLE.md +0 -96
  561. {scitex-2.7.3.dist-info → scitex-2.10.0.dist-info}/WHEEL +0 -0
  562. {scitex-2.7.3.dist-info → scitex-2.10.0.dist-info}/entry_points.txt +0 -0
  563. {scitex-2.7.3.dist-info → scitex-2.10.0.dist-info}/licenses/LICENSE +0 -0
scitex/io/_save.py CHANGED
@@ -1,151 +1,65 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- # Timestamp: "2025-11-14 08:56:29 (ywatanabe)"
2
+ # Timestamp: 2025-12-19
4
3
  # File: /home/ywatanabe/proj/scitex-code/src/scitex/io/_save.py
5
4
 
6
-
7
- import os
8
-
9
- __FILE__ = __file__
10
-
11
- import warnings
12
-
13
-
14
5
  """
15
- 1. Functionality:
16
- - Provides utilities for saving various data types to different file formats.
17
- 2. Input:
18
- - Objects to be saved (e.g., NumPy arrays, PyTorch tensors, Pandas DataFrames, etc.)
19
- - File path or name where the object should be saved
20
- 3. Output:
21
- - Saved files in various formats (e.g., CSV, NPY, PKL, JOBLIB, PNG, HTML, TIFF, MP4, YAML, JSON, HDF5, PTH, MAT, CBM)
22
- 4. Prerequisites:
23
- - Python 3.x
24
- - Required libraries: numpy, pandas, torch, matplotlib, plotly, h5py, joblib, PIL, ruamel.yaml
6
+ Save utilities for various data types to different file formats.
7
+
8
+ Supported formats include CSV, NPY, PKL, JOBLIB, PNG, HTML, TIFF, MP4, YAML,
9
+ JSON, HDF5, PTH, MAT, CBM, and FTS bundles (.zip or directory).
25
10
  """
26
11
 
27
- """Imports"""
28
12
  import inspect
29
13
  import os as _os
30
14
  from pathlib import Path
31
- from typing import Any
32
- from typing import Union
15
+ from typing import Any, Union
33
16
 
34
17
  from scitex import logging
35
-
36
- from scitex.sh import sh
37
18
  from scitex.path._clean import clean
38
19
  from scitex.path._getsize import getsize
20
+ from scitex.sh import sh
39
21
  from scitex.str._clean_path import clean_path
40
22
  from scitex.str._color_text import color_text
41
23
  from scitex.str._readable_bytes import readable_bytes
42
24
 
43
- # Import save functions from the new modular structure
44
- from ._save_modules import save_catboost
45
- from ._save_modules import save_csv
46
- from ._save_modules import save_excel
47
- from ._save_modules import save_hdf5
48
- from ._save_modules import save_html
49
- from ._save_modules import save_image
50
- from ._save_modules import save_joblib
51
- from ._save_modules import save_json
52
- from ._save_modules import save_matlab
53
- from ._save_modules import save_mp4
54
- from ._save_modules import save_npy
55
- from ._save_modules import save_npz
56
- from ._save_modules import save_pickle
57
- from ._save_modules import save_pickle_compressed
58
- from ._save_modules import save_tex
59
- from ._save_modules import save_text
60
- from ._save_modules import save_torch
61
- from ._save_modules import save_yaml
62
- from ._save_modules import save_zarr
63
- from ._save_modules._bibtex import save_bibtex
64
- from ._save_modules._canvas import save_canvas
25
+ # Import save functions from the modular structure
26
+ from ._save_modules import (
27
+ get_figure_with_data,
28
+ handle_image_with_csv,
29
+ save_bibtex,
30
+ save_catboost,
31
+ save_csv,
32
+ save_excel,
33
+ save_hdf5,
34
+ save_html,
35
+ save_joblib,
36
+ save_json,
37
+ save_matlab,
38
+ save_mp4,
39
+ save_npy,
40
+ save_npz,
41
+ save_pickle,
42
+ save_pickle_compressed,
43
+ save_pltz_bundle,
44
+ save_stx_bundle,
45
+ save_tex,
46
+ save_text,
47
+ save_torch,
48
+ save_yaml,
49
+ save_zarr,
50
+ symlink,
51
+ symlink_to,
52
+ )
65
53
 
66
54
  logger = logging.getLogger()
67
55
 
68
-
69
- def _get_figure_with_data(obj):
70
- """
71
- Extract figure or axes object that may contain plotting data for CSV export.
72
-
73
- Parameters
74
- ----------
75
- obj : various matplotlib objects
76
- Could be Figure, Axes, FigWrapper, AxisWrapper, or other matplotlib objects
77
-
78
- Returns
79
- -------
80
- object or None
81
- Figure or axes object that has export_as_csv methods, or None if not found
82
- """
83
- import matplotlib.axes
84
- import matplotlib.figure
85
- import matplotlib.pyplot as plt
86
-
87
- # Check if object already has export methods (SciTeX wrapped objects)
88
- if hasattr(obj, "export_as_csv"):
89
- return obj
90
-
91
- # Handle matplotlib Figure objects
92
- if isinstance(obj, matplotlib.figure.Figure):
93
- # Get the current axes that might be wrapped with SciTeX functionality
94
- current_ax = plt.gca()
95
- if hasattr(current_ax, "export_as_csv"):
96
- return current_ax
97
-
98
- # Check all axes in the figure
99
- for ax in obj.axes:
100
- if hasattr(ax, "export_as_csv"):
101
- return ax
102
-
103
- return None
104
-
105
- # Handle matplotlib Axes objects
106
- if isinstance(obj, matplotlib.axes.Axes):
107
- if hasattr(obj, "export_as_csv"):
108
- return obj
109
- return None
110
-
111
- # Handle FigWrapper or similar SciTeX objects
112
- if hasattr(obj, "figure") and hasattr(obj.figure, "axes"):
113
- # Check if the wrapper itself has export methods
114
- if hasattr(obj, "export_as_csv"):
115
- return obj
116
-
117
- # Check the underlying figure's axes
118
- for ax in obj.figure.axes:
119
- if hasattr(ax, "export_as_csv"):
120
- return ax
121
-
122
- return None
123
-
124
- # Handle AxisWrapper or similar SciTeX objects
125
- if hasattr(obj, "_axis_mpl") or hasattr(obj, "_ax"):
126
- if hasattr(obj, "export_as_csv"):
127
- return obj
128
- return None
129
-
130
- # Try to get the current figure and its axes as fallback
131
- try:
132
- current_fig = plt.gcf()
133
- current_ax = plt.gca()
134
-
135
- if hasattr(current_ax, "export_as_csv"):
136
- return current_ax
137
- elif hasattr(current_fig, "export_as_csv"):
138
- return current_fig
139
-
140
- # Check all axes in current figure
141
- for ax in current_fig.axes:
142
- if hasattr(ax, "export_as_csv"):
143
- return ax
144
-
145
- except:
146
- pass
147
-
148
- return None
56
+ # Re-export for backward compatibility
57
+ _get_figure_with_data = get_figure_with_data
58
+ _symlink = symlink
59
+ _symlink_to = symlink_to
60
+ _save_stx_bundle = save_stx_bundle
61
+ _save_pltz_bundle = save_pltz_bundle
62
+ _handle_image_with_csv = handle_image_with_csv
149
63
 
150
64
 
151
65
  def save(
@@ -170,264 +84,58 @@ def save(
170
84
  Parameters
171
85
  ----------
172
86
  obj : Any
173
- The object to be saved. Can be a NumPy array, PyTorch tensor, Pandas DataFrame, or any serializable object.
87
+ The object to be saved.
174
88
  specified_path : Union[str, Path]
175
- The file name or path where the object should be saved. Can be a string or pathlib.Path object. The file extension determines the format.
89
+ The file path where the object should be saved.
176
90
  makedirs : bool, optional
177
91
  If True, create the directory path if it does not exist. Default is True.
178
92
  verbose : bool, optional
179
93
  If True, print a message upon successful saving. Default is True.
180
94
  symlink_from_cwd : bool, optional
181
- If True, create a _symlink from the current working directory. Default is False.
95
+ If True, create a symlink from the current working directory. Default is False.
182
96
  symlink_to : Union[str, Path], optional
183
- If specified, create a symlink at this path pointing to the saved file. Default is None.
97
+ If specified, create a symlink at this path. Default is None.
184
98
  dry_run : bool, optional
185
- If True, simulate the saving process without actually writing files. Default is False.
99
+ If True, simulate the saving process. Default is False.
186
100
  auto_crop : bool, optional
187
- If True, automatically crop the saved image to content area with margin (for PNG/JPEG/TIFF).
188
- Vector formats (PDF/SVG) are not cropped. Default is True.
101
+ If True, automatically crop saved images. Default is True.
189
102
  crop_margin_mm : float, optional
190
- Margin in millimeters to add around content when auto_crop=True.
191
- At 300 DPI: 1mm = ~12 pixels. Default is 1.0mm (Nature Reviews style).
103
+ Margin in millimeters for auto_crop. Default is 1.0mm.
192
104
  use_caller_path : bool, optional
193
- If True, intelligently determine the script path by skipping internal library frames.
194
- This is useful when stx.io.save is called from within scitex library code.
195
- Default is False.
105
+ If True, determine script path by skipping internal library frames.
196
106
  metadata_extra : dict, optional
197
- Additional metadata to merge with auto-collected metadata. Useful for specifying
198
- plot_type, style information, etc. Example:
199
- metadata_extra = {
200
- "plot_type": "line",
201
- "style": {
202
- "name": "SCITEX_STYLE",
203
- "overrides": {"ax_width_mm": 50}
204
- }
205
- }
206
- Default is None.
107
+ Additional metadata to merge with auto-collected metadata.
207
108
  json_schema : str, optional
208
- Schema type for JSON metadata output. Options:
209
- - "editable": Schema v0.3.0 with element geometry for interactive editing (default)
210
- - "recipe": Minimal schema with method calls + data refs
211
- - "verbose": Full schema with all artist details
212
- Default is "editable".
109
+ Schema type for JSON metadata output. Default is "editable".
213
110
  **kwargs
214
- Additional keyword arguments to pass to the underlying save function of the specific format.
215
-
216
- Returns
217
- -------
218
- None
219
-
220
- Notes
221
- -----
222
- Supported formats include CSV, NPY, PKL, JOBLIB, PNG, HTML, TIFF, MP4, YAML, JSON, HDF5, PTH, MAT, CBM,
223
- and SciTeX bundles (.figz, .pltz, .statsz).
224
- The function dynamically selects the appropriate saving mechanism based on the file extension.
225
-
226
- Bundle Formats:
227
- - .figz: Publication figure bundle (panels dict). Default: ZIP archive.
228
- - .pltz: Plot bundle (matplotlib figure). Default: directory bundle.
229
- - .statsz: Statistics bundle (comparisons list). Default: directory bundle.
230
- - Use .d suffix (e.g., "Figure1.figz.d") to force directory format for .figz.
231
-
232
- Examples
233
- --------
234
- >>> import scitex
235
- >>> import numpy as np
236
- >>> import pandas as pd
237
- >>> import torch
238
- >>> import matplotlib.pyplot as plt
239
-
240
- >>> # Save NumPy array
241
- >>> arr = np.array([1, 2, 3])
242
- >>> scitex.io.save(arr, "data.npy")
243
-
244
- >>> # Save Pandas DataFrame
245
- >>> df = pd.DataFrame({"col1": [1, 2], "col2": [3, 4]})
246
- >>> scitex.io.save(df, "data.csv")
247
-
248
- >>> # Save PyTorch tensor
249
- >>> tensor = torch.tensor([1, 2, 3])
250
- >>> scitex.io.save(tensor, "model.pth")
251
-
252
- >>> # Save dictionary
253
- >>> data_dict = {"a": 1, "b": 2, "c": [3, 4, 5]}
254
- >>> scitex.io.save(data_dict, "data.pkl")
255
-
256
- >>> # Save matplotlib figure
257
- >>> plt.figure()
258
- >>> plt.plot(np.array([1, 2, 3]))
259
- >>> scitex.io.save(plt, "plot.png")
260
-
261
- >>> # Save as YAML
262
- >>> scitex.io.save(data_dict, "config.yaml")
263
-
264
- >>> # Save as JSON
265
- >>> scitex.io.save(data_dict, "data.json")
111
+ Additional keyword arguments for the underlying save function.
266
112
  """
267
113
  try:
268
- # Convert Path objects to strings for consistency
269
114
  if isinstance(specified_path, Path):
270
115
  specified_path = str(specified_path)
271
116
 
272
- ########################################
273
- # DO NOT MODIFY THIS SECTION
274
- ########################################
275
- #
276
- # Determine saving directory from the script.
277
- #
278
- # When called in /path/to/script.py,
279
- # data will be saved under `/path/to/script.py_out/`
280
- #
281
- # When called in a Jupyter notebook /path/to/notebook.ipynb,
282
- # data will be saved under `/path/to/notebook_out/`
283
- #
284
- # When called in ipython environment,
285
- # data will be saved under `/tmp/{_os.getenv("USER")/`
286
- #
287
- ########################################
288
- spath, sfname = None, None
289
-
290
- # f-expression handling - safely parse f-strings
291
- if specified_path.startswith('f"') or specified_path.startswith("f'"):
292
- # Remove the f prefix and quotes
293
- path_content = specified_path[2:-1]
294
-
295
- # Get the caller's frame to access their local variables
296
- frame = inspect.currentframe().f_back
297
- try:
298
- # Use string formatting with the caller's locals and globals
299
- # This is much safer than eval() as it only does string substitution
300
- import re
301
-
302
- # Find all {variable} patterns
303
- variables = re.findall(r"\{([^}]+)\}", path_content)
304
- format_dict = {}
305
- for var in variables:
306
- # Only allow simple variable names, not arbitrary expressions
307
- if re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", var):
308
- if var in frame.f_locals:
309
- format_dict[var] = frame.f_locals[var]
310
- elif var in frame.f_globals:
311
- format_dict[var] = frame.f_globals[var]
312
- else:
313
- raise ValueError(f"Invalid variable name in f-string: {var}")
314
-
315
- # Use str.format() which is safe
316
- specified_path = path_content.format(**format_dict)
317
- finally:
318
- del frame # Avoid reference cycles
319
-
320
- # When full path
321
- if specified_path.startswith("/"):
322
- spath = specified_path
323
-
324
- # When relative path
325
- else:
326
- # Import here to avoid circular imports
327
- from scitex.gen._detect_environment import detect_environment
328
- from scitex.gen._get_notebook_path import get_notebook_info_simple
329
-
330
- # Detect the current environment
331
- env_type = detect_environment()
332
-
333
- if env_type == "jupyter":
334
- # Special handling for Jupyter notebooks
335
- notebook_name, notebook_dir = get_notebook_info_simple()
336
-
337
- if notebook_name:
338
- # Remove .ipynb extension and add _out
339
- notebook_base = _os.path.splitext(notebook_name)[0]
340
- sdir = _os.path.join(
341
- notebook_dir or _os.getcwd(), f"{notebook_base}_out"
342
- )
343
- else:
344
- # Fallback if we can't detect notebook name
345
- sdir = _os.path.join(_os.getcwd(), "notebook_out")
346
-
347
- spath = _os.path.join(sdir, specified_path)
348
-
349
- elif env_type == "script":
350
- # Regular script handling
351
- if use_caller_path:
352
- # Smart path detection: skip internal scitex library frames
353
- script_path = None
354
- scitex_src_path = _os.path.join(
355
- _os.path.dirname(__file__), "..", ".."
356
- )
357
- scitex_src_path = _os.path.abspath(scitex_src_path)
358
-
359
- # Walk through the call stack from caller to find the first non-scitex frame
360
- for frame_info in inspect.stack()[1:]:
361
- frame_path = _os.path.abspath(frame_info.filename)
362
- # Skip frames from scitex library
363
- if not frame_path.startswith(scitex_src_path):
364
- script_path = frame_path
365
- break
366
-
367
- # Fallback to stack[1] if we couldn't find a non-scitex frame
368
- if script_path is None:
369
- script_path = inspect.stack()[1].filename
370
- else:
371
- script_path = inspect.stack()[1].filename
372
-
373
- sdir = clean_path(_os.path.splitext(script_path)[0] + "_out")
374
- spath = _os.path.join(sdir, specified_path)
117
+ # Handle f-string expressions
118
+ specified_path = _parse_fstring_path(specified_path)
375
119
 
376
- else:
377
- # IPython console or interactive mode
378
- script_path = inspect.stack()[1].filename
379
-
380
- if (
381
- ("ipython" in script_path)
382
- or ("<stdin>" in script_path)
383
- or env_type in ["ipython", "interactive"]
384
- ):
385
- script_path = f"/tmp/{_os.getenv('USER')}"
386
- sdir = script_path
387
- else:
388
- # Unknown environment, use current directory
389
- sdir = _os.path.join(_os.getcwd(), "output")
390
-
391
- spath = _os.path.join(sdir, specified_path)
392
-
393
- # Sanitization
120
+ # Determine save path
121
+ spath = _determine_save_path(specified_path, use_caller_path)
394
122
  spath_final = clean(spath)
395
- ########################################
396
123
 
397
- # Potential path to _symlink
124
+ # Prepare symlink path from cwd
398
125
  spath_cwd = _os.getcwd() + "/" + specified_path
399
126
  spath_cwd = clean(spath_cwd)
400
127
 
401
- # Removes spath and spath_cwd to prevent potential circular links
402
- # Skip deletion for CSV files to allow caching to work
403
- # Also skip deletion for HDF5 files when a key is specified
404
- should_skip_deletion = spath_final.endswith(".csv") or (
405
- (spath_final.endswith(".hdf5") or spath_final.endswith(".h5"))
406
- and "key" in kwargs
407
- )
408
-
409
- if not should_skip_deletion:
410
- for path in [spath_final, spath_cwd]:
411
- sh(["rm", "-f", f"{path}"], verbose=False)
128
+ # Remove existing files (skip for CSV/HDF5 with key)
129
+ _cleanup_existing_files(spath_final, spath_cwd, kwargs)
412
130
 
413
131
  if dry_run:
414
- # Get relative path from current working directory
415
- try:
416
- rel_path = _os.path.relpath(spath, _os.getcwd())
417
- except ValueError:
418
- rel_path = spath
419
-
420
- if verbose:
421
- logger.success(
422
- color_text(f"(dry run) Saved to: ./{rel_path}", c="yellow")
423
- )
132
+ _handle_dry_run(spath, verbose)
424
133
  return
425
134
 
426
- # Ensure directory exists
427
135
  if makedirs:
428
136
  _os.makedirs(_os.path.dirname(spath_final), exist_ok=True)
429
137
 
430
- # Main
138
+ # Main save
431
139
  _save(
432
140
  obj,
433
141
  spath_final,
@@ -447,536 +155,110 @@ def save(
447
155
  _symlink(spath, spath_cwd, symlink_from_cwd, verbose)
448
156
  _symlink_to(spath_final, symlink_to, verbose)
449
157
  return Path(spath)
450
- # return True
451
158
 
452
159
  except AssertionError:
453
- # Re-raise assertion errors - these are validation failures that should stop execution
454
160
  raise
455
161
  except Exception as e:
456
- logger.error(
457
- f"Error occurred while saving: {str(e)}\n"
458
- f"Debug: Initial script_path = {inspect.stack()[1].filename}\n"
459
- f"Debug: Final spath = {spath}\n"
460
- f"Debug: specified_path type = {type(specified_path)}\n"
461
- f"Debug: specified_path = {specified_path}"
462
- )
162
+ logger.error(f"Error occurred while saving: {str(e)}")
463
163
  return False
464
164
 
465
165
 
466
- def _symlink(spath, spath_cwd, symlink_from_cwd, verbose):
467
- """Create a symbolic link from the current working directory."""
468
- if symlink_from_cwd and (spath != spath_cwd):
469
- _os.makedirs(_os.path.dirname(spath_cwd), exist_ok=True)
470
- sh(["rm", "-f", f"{spath_cwd}"], verbose=False)
471
- sh(["ln", "-sfr", f"{spath}", f"{spath_cwd}"], verbose=False)
472
- if verbose:
473
- # Get file extension to provide more informative message
474
- ext = _os.path.splitext(spath_cwd)[1].lower()
475
- logger.success(color_text(f"(Symlinked to: {spath_cwd})"))
476
-
477
-
478
- def _symlink_to(spath_final, symlink_to, verbose):
479
- """Create a symbolic link at the specified path pointing to the saved file."""
480
- if symlink_to:
481
- # Convert Path objects to strings for consistency
482
- if isinstance(symlink_to, Path):
483
- symlink_to = str(symlink_to)
484
-
485
- # Clean the symlink path
486
- symlink_to = clean(symlink_to)
487
-
488
- # Ensure the symlink directory exists (only if there is a directory component)
489
- symlink_dir = _os.path.dirname(symlink_to)
490
- if symlink_dir: # Only create directory if there's a directory component
491
- _os.makedirs(symlink_dir, exist_ok=True)
492
-
493
- # Remove existing symlink or file
494
- sh(["rm", "-f", f"{symlink_to}"], verbose=False)
166
+ def _parse_fstring_path(specified_path):
167
+ """Parse f-string expressions in path."""
168
+ if not (specified_path.startswith('f"') or specified_path.startswith("f'")):
169
+ return specified_path
495
170
 
496
- # Create the symlink using relative path for robustness
497
- sh(["ln", "-sfr", f"{spath_final}", f"{symlink_to}"], verbose=False)
171
+ import re
498
172
 
499
- if verbose:
500
- symlink_to_full = (
501
- os.path.realpath(symlink_to) + "/" + os.path.basename(spath_final)
502
- )
503
- logger.success(f"Symlinked: {spath_final} -> {symlink_to_full}")
504
-
505
-
506
- def _save_pltz_bundle(obj, spath, as_zip=False, data=None, layered=True, **kwargs):
507
- """Save a matplotlib figure as a .pltz bundle.
173
+ path_content = specified_path[2:-1]
174
+ frame = inspect.currentframe().f_back.f_back
175
+ try:
176
+ variables = re.findall(r"\{([^}]+)\}", path_content)
177
+ format_dict = {}
178
+ for var in variables:
179
+ if re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", var):
180
+ if var in frame.f_locals:
181
+ format_dict[var] = frame.f_locals[var]
182
+ elif var in frame.f_globals:
183
+ format_dict[var] = frame.f_globals[var]
184
+ else:
185
+ raise ValueError(f"Invalid variable name in f-string: {var}")
186
+ return path_content.format(**format_dict)
187
+ finally:
188
+ del frame
508
189
 
509
- Bundle structure v2.0 (layered - default):
510
- plot.pltz.d/
511
- spec.json # Semantic: WHAT to plot (canonical)
512
- style.json # Appearance: HOW it looks (canonical)
513
- data.csv # Raw data (immutable)
514
- exports/ # PNG, SVG, hitmap
515
- cache/ # geometry_px.json, render_manifest.json
516
190
 
517
- Bundle structure v1.0 (legacy):
518
- plot.json - specification (axes, styles, theme, etc.)
519
- plot.csv - raw data (immutable)
520
- plot.png - raster export (required)
521
- plot.svg - vector export (optional)
522
- plot.pdf - publication export (optional)
191
+ def _determine_save_path(specified_path, use_caller_path):
192
+ """Determine the full save path based on environment."""
193
+ if specified_path.startswith("/"):
194
+ return specified_path
523
195
 
524
- Parameters
525
- ----------
526
- obj : matplotlib.figure.Figure
527
- The figure to save.
528
- spath : str or Path
529
- Output path (e.g., "plot.pltz.d" or "plot.pltz").
530
- as_zip : bool
531
- If True, save as ZIP archive.
532
- data : pandas.DataFrame, optional
533
- Data to embed in the bundle as plot.csv.
534
- layered : bool
535
- If True (default), use new layered format (spec/style/geometry).
536
- If False, use legacy single JSON format.
537
- **kwargs
538
- Additional arguments passed to savefig.
539
- """
540
- from pathlib import Path
541
- import tempfile
542
- import json
543
- import numpy as np
544
- from ._bundle import save_bundle, BundleType
545
-
546
- p = Path(spath)
547
-
548
- # Extract basename from path (e.g., "myplot.pltz" -> "myplot", "myplot.pltz.d" -> "myplot")
549
- basename = p.stem # e.g., "myplot.pltz" or "myplot"
550
- if basename.endswith('.pltz'):
551
- basename = basename[:-5] # Remove .pltz suffix
552
- elif basename.endswith('.d'):
553
- # Handle myplot.pltz.d -> myplot.pltz -> myplot
554
- basename = Path(basename).stem
555
- if basename.endswith('.pltz'):
556
- basename = basename[:-5]
557
-
558
- # Extract figure from various matplotlib object types
559
- import matplotlib.figure
560
- fig = obj
561
- if hasattr(obj, 'figure'):
562
- fig = obj.figure
563
- elif hasattr(obj, 'fig'):
564
- fig = obj.fig
196
+ from scitex.gen._detect_environment import detect_environment
197
+ from scitex.gen._get_notebook_path import get_notebook_info_simple
565
198
 
566
- if not isinstance(fig, matplotlib.figure.Figure):
567
- raise TypeError(f"Expected matplotlib Figure, got {type(obj).__name__}")
199
+ env_type = detect_environment()
568
200
 
569
- dpi = kwargs.pop('dpi', 300)
201
+ if env_type == "jupyter":
202
+ notebook_name, notebook_dir = get_notebook_info_simple()
203
+ if notebook_name:
204
+ notebook_base = _os.path.splitext(notebook_name)[0]
205
+ sdir = _os.path.join(notebook_dir or _os.getcwd(), f"{notebook_base}_out")
206
+ else:
207
+ sdir = _os.path.join(_os.getcwd(), "notebook_out")
208
+ return _os.path.join(sdir, specified_path)
570
209
 
571
- # === Always use layered format ===
572
- from scitex.plt.io import save_layered_pltz_bundle
573
- import shutil
574
- import tempfile
210
+ elif env_type == "script":
211
+ if use_caller_path:
212
+ script_path = _find_caller_script_path()
213
+ else:
214
+ script_path = inspect.stack()[2].filename
215
+ sdir = clean_path(_os.path.splitext(script_path)[0] + "_out")
216
+ return _os.path.join(sdir, specified_path)
575
217
 
576
- # Determine bundle directory path
577
- if as_zip:
578
- # For ZIP: save to temp dir, then compress
579
- temp_dir = Path(tempfile.mkdtemp())
580
- bundle_dir = temp_dir / f"{basename}.pltz.d"
581
- zip_path = p if not str(p).endswith('.d') else Path(str(p)[:-2])
582
218
  else:
583
- # For directory: save directly
584
- bundle_dir = p if str(p).endswith('.d') else Path(str(p) + '.d')
585
-
586
- # Get CSV data from figure if not provided
587
- csv_df = data
588
- if csv_df is None:
589
- csv_source = _get_figure_with_data(obj)
590
- if csv_source is not None and hasattr(csv_source, 'export_as_csv'):
591
- try:
592
- csv_df = csv_source.export_as_csv()
593
- except Exception:
594
- pass
595
-
596
- save_layered_pltz_bundle(
597
- fig=fig,
598
- bundle_dir=bundle_dir,
599
- basename=basename,
600
- dpi=dpi,
601
- csv_df=csv_df,
602
- )
603
-
604
- # Compress to ZIP if requested
605
- if as_zip:
606
- from ._bundle import pack_bundle
607
- pack_bundle(bundle_dir, zip_path)
608
- shutil.rmtree(temp_dir) # Clean up temp directory
609
-
610
- return # Done with layered format
611
-
612
- # === Legacy format below (DEPRECATED - kept for reference) ===
613
-
614
- # Calculate size info
615
- fig_width_inch, fig_height_inch = fig.get_size_inches()
616
- fig_dpi = fig.get_dpi()
617
-
618
- # Build spec according to contract (using basename for file references)
619
- spec = {
620
- 'schema': {'name': 'scitex.plt.plot', 'version': '1.0.0'},
621
- 'backend': 'mpl',
622
- 'data': {
623
- 'source': f'{basename}.csv',
624
- 'path': f'{basename}.csv',
625
- 'hash': None, # Will be computed after data extraction
626
- 'columns': [], # Will be populated after data extraction
627
- },
628
- 'size': {
629
- 'width_inch': round(fig_width_inch, 2),
630
- 'height_inch': round(fig_height_inch, 2),
631
- 'width_mm': round(fig_width_inch * 25.4, 2),
632
- 'height_mm': round(fig_height_inch * 25.4, 2),
633
- 'width_px': int(fig_width_inch * dpi),
634
- 'height_px': int(fig_height_inch * dpi),
635
- 'dpi': dpi,
636
- 'crop_margin_mm': 1.0,
637
- },
638
- 'axes': [],
639
- 'theme': {
640
- 'mode': 'light',
641
- 'colors': {
642
- 'background': 'transparent',
643
- 'axes_bg': 'white',
644
- 'text': 'black',
645
- 'spine': 'black',
646
- 'tick': 'black',
647
- }
648
- },
649
- }
650
-
651
- # Extract data from plot lines if no data provided
652
- extracted_data = {}
653
-
654
- # Extract axes metadata
655
- for i, ax in enumerate(fig.axes):
656
- # Get axes bounding box in figure coordinates (0-1)
657
- bbox = ax.get_position()
658
-
659
- ax_info = {
660
- 'xlabel': ax.get_xlabel() or None,
661
- 'ylabel': ax.get_ylabel() or None,
662
- 'title': ax.get_title() or None,
663
- 'xlim': [round(v, 2) for v in ax.get_xlim()],
664
- 'ylim': [round(v, 2) for v in ax.get_ylim()],
665
- 'plot_type': 'line', # Default, could be detected
666
- # Bounding box in normalized figure coordinates (0-1)
667
- 'bbox': {
668
- 'x0': round(bbox.x0, 4),
669
- 'y0': round(bbox.y0, 4),
670
- 'x1': round(bbox.x1, 4),
671
- 'y1': round(bbox.y1, 4),
672
- 'width': round(bbox.width, 4),
673
- 'height': round(bbox.height, 4),
674
- },
675
- # Bounding box in mm
676
- 'bbox_mm': {
677
- 'x0': round(bbox.x0 * fig_width_inch * 25.4, 2),
678
- 'y0': round(bbox.y0 * fig_height_inch * 25.4, 2),
679
- 'x1': round(bbox.x1 * fig_width_inch * 25.4, 2),
680
- 'y1': round(bbox.y1 * fig_height_inch * 25.4, 2),
681
- 'width': round(bbox.width * fig_width_inch * 25.4, 2),
682
- 'height': round(bbox.height * fig_height_inch * 25.4, 2),
683
- },
684
- # Bounding box in pixels
685
- 'bbox_px': {
686
- 'x0': int(bbox.x0 * fig_width_inch * dpi),
687
- 'y0': int(bbox.y0 * fig_height_inch * dpi),
688
- 'x1': int(bbox.x1 * fig_width_inch * dpi),
689
- 'y1': int(bbox.y1 * fig_height_inch * dpi),
690
- 'width': int(bbox.width * fig_width_inch * dpi),
691
- 'height': int(bbox.height * fig_height_inch * dpi),
692
- },
693
- }
694
-
695
- # SciTeX-specific axis dimensions
696
- if hasattr(ax, '_scitex_axes_width_mm'):
697
- ax_info['axes_width_mm'] = ax._scitex_axes_width_mm
219
+ script_path = inspect.stack()[2].filename
220
+ if (
221
+ ("ipython" in script_path)
222
+ or ("<stdin>" in script_path)
223
+ or env_type in ["ipython", "interactive"]
224
+ ):
225
+ sdir = f"/tmp/{_os.getenv('USER')}"
698
226
  else:
699
- ax_info['axes_width_mm'] = round(bbox.width * fig_width_inch * 25.4, 1)
227
+ sdir = _os.path.join(_os.getcwd(), "output")
228
+ return _os.path.join(sdir, specified_path)
700
229
 
701
- if hasattr(ax, '_scitex_axes_height_mm'):
702
- ax_info['axes_height_mm'] = ax._scitex_axes_height_mm
703
- else:
704
- ax_info['axes_height_mm'] = round(bbox.height * fig_height_inch * 25.4, 1)
705
-
706
- # Extract line data for CSV and build lines array
707
- lines_info = []
708
- for j, line in enumerate(ax.get_lines()):
709
- label = line.get_label()
710
- if label is None or label.startswith('_'):
711
- label = f'series_{j}'
712
- xdata, ydata = line.get_data()
713
- if len(xdata) > 0:
714
- col_x = f'{label}_x' if i == 0 else f'ax{i}_{label}_x'
715
- col_y = f'{label}_y' if i == 0 else f'ax{i}_{label}_y'
716
- extracted_data[col_x] = np.array(xdata)
717
- extracted_data[col_y] = np.array(ydata)
718
-
719
- # Get line color (convert RGBA to hex)
720
- color = line.get_color()
721
- if isinstance(color, (list, tuple)):
722
- import matplotlib.colors as mcolors
723
- color = mcolors.to_hex(color)
724
-
725
- lines_info.append({
726
- 'label': label,
727
- 'x_col': col_x,
728
- 'y_col': col_y,
729
- 'color': color,
730
- 'linewidth': line.get_linewidth(),
731
- })
732
-
733
- if lines_info:
734
- ax_info['lines'] = lines_info
735
-
736
- spec['axes'].append(ax_info)
737
-
738
- # Handle theme from figure
739
- if hasattr(fig, '_scitex_theme'):
740
- theme_mode = fig._scitex_theme
741
- spec['theme']['mode'] = theme_mode
742
- # Update colors based on theme mode
743
- if theme_mode == 'dark':
744
- spec['theme']['colors'] = {
745
- 'background': 'transparent',
746
- 'axes_bg': 'transparent',
747
- 'text': '#e8e8e8',
748
- 'spine': '#e8e8e8',
749
- 'tick': '#e8e8e8',
750
- }
751
- # Re-apply theme colors to ensure legends and other elements get the correct colors
752
- from scitex.plt.utils._figure_mm import _apply_theme_colors
753
- for ax in fig.axes:
754
- _apply_theme_colors(ax, theme='dark')
755
-
756
- # Build bundle data (include basename for file naming)
757
- bundle_data = {'spec': spec, 'basename': basename}
758
-
759
- # Use provided data or extracted data for CSV
760
- # Priority: 1) explicit data param, 2) export_as_csv method, 3) line extraction fallback
761
- csv_df = None
762
- if data is not None:
763
- csv_df = data
764
- bundle_data['data'] = data
765
- else:
766
- # Try to use export_as_csv from SciTeX wrapped objects (handles all plot types)
767
- csv_source = _get_figure_with_data(obj)
768
- if csv_source is not None and hasattr(csv_source, 'export_as_csv'):
769
- try:
770
- csv_df = csv_source.export_as_csv()
771
- if csv_df is not None and not csv_df.empty:
772
- bundle_data['data'] = csv_df
773
- logger.debug(f"CSV data extracted via export_as_csv: {len(csv_df)} rows, {len(csv_df.columns)} cols")
774
- except Exception as e:
775
- logger.debug(f"export_as_csv failed: {e}")
776
- csv_df = None
777
-
778
- # Fallback to line extraction if export_as_csv didn't work
779
- if csv_df is None and extracted_data:
780
- try:
781
- import pandas as pd
782
- # Pad arrays to same length
783
- max_len = max(len(v) for v in extracted_data.values())
784
- padded = {}
785
- for k, v in extracted_data.items():
786
- if len(v) < max_len:
787
- padded[k] = np.pad(v, (0, max_len - len(v)), constant_values=np.nan)
788
- else:
789
- padded[k] = v
790
- csv_df = pd.DataFrame(padded)
791
- bundle_data['data'] = csv_df
792
- logger.debug(f"CSV data extracted via line fallback: {len(csv_df)} rows")
793
- except ImportError:
794
- pass
795
230
 
796
- # Compute hash and columns for data section
797
- if csv_df is not None:
798
- import hashlib
799
- # Get CSV string for hash computation
800
- csv_str = csv_df.to_csv(index=False)
801
- csv_hash = hashlib.sha256(csv_str.encode()).hexdigest()
802
- spec['data']['hash'] = f'sha256:{csv_hash[:16]}'
803
- spec['data']['columns'] = list(csv_df.columns)
804
-
805
- # Save figure to multiple formats
806
- import warnings
807
- from PIL import Image as PILImage
808
- from scitex.plt.utils._hitmap import (
809
- apply_hitmap_colors, restore_original_colors, extract_path_data,
810
- extract_selectable_regions, HITMAP_BACKGROUND_COLOR, HITMAP_AXES_COLOR
231
+ def _find_caller_script_path():
232
+ """Find the first non-scitex frame in the call stack."""
233
+ scitex_src_path = _os.path.abspath(
234
+ _os.path.join(_os.path.dirname(__file__), "..", "..")
811
235
  )
236
+ for frame_info in inspect.stack()[3:]:
237
+ frame_path = _os.path.abspath(frame_info.filename)
238
+ if not frame_path.startswith(scitex_src_path):
239
+ return frame_path
240
+ return inspect.stack()[2].filename
241
+
242
+
243
+ def _cleanup_existing_files(spath_final, spath_cwd, kwargs):
244
+ """Remove existing files to prevent circular links."""
245
+ should_skip = spath_final.endswith(".csv") or (
246
+ (spath_final.endswith(".hdf5") or spath_final.endswith(".h5"))
247
+ and "key" in kwargs
248
+ )
249
+ if not should_skip:
250
+ for path in [spath_final, spath_cwd]:
251
+ sh(["rm", "-f", f"{path}"], verbose=False)
812
252
 
813
- crop_box = None
814
- color_map = {}
815
-
816
- with tempfile.TemporaryDirectory() as tmp_dir:
817
- tmp_path = Path(tmp_dir)
818
-
819
- # Suppress tight_layout warnings for SciTeX figures with custom axes
820
- with warnings.catch_warnings():
821
- warnings.filterwarnings('ignore', message='.*tight_layout.*')
822
-
823
- # Always use transparent background for SciTeX figures (both light and dark themes)
824
- use_transparent = True
825
-
826
- # Save PNG (raster) - required
827
- png_path = tmp_path / "plot.png"
828
- fig.savefig(png_path, dpi=dpi, bbox_inches='tight', format='png', transparent=use_transparent)
829
-
830
- # Save SVG (vector) - optional
831
- svg_path = tmp_path / "plot.svg"
832
- fig.savefig(svg_path, bbox_inches='tight', format='svg')
833
-
834
- # Save PDF (vector) - optional
835
- pdf_path = tmp_path / "plot.pdf"
836
- fig.savefig(pdf_path, bbox_inches='tight', format='pdf')
837
-
838
- # Now generate hitmap by applying ID colors to data elements ONLY
839
- # Keep axes/spines/labels with original colors to preserve bbox_inches='tight' bounds
840
- # Also detects logical groups (histogram, bar_series, etc.)
841
- original_props, color_map, groups = apply_hitmap_colors(fig)
842
-
843
- # Store original background colors and set hitmap colors
844
- original_fig_facecolor = fig.patch.get_facecolor()
845
- original_ax_facecolors = []
846
- original_ax_props = []
847
- for ax in fig.axes:
848
- original_ax_facecolors.append(ax.get_facecolor())
849
- # Store axis element colors for restoration
850
- ax_props = {
851
- 'ax': ax,
852
- 'spine_colors': {k: v.get_edgecolor() for k, v in ax.spines.items()},
853
- 'tick_colors': ax.tick_params, # Will restore later
854
- 'xlabel_color': ax.xaxis.label.get_color(),
855
- 'ylabel_color': ax.yaxis.label.get_color(),
856
- 'title_color': ax.title.get_color(),
857
- }
858
- original_ax_props.append(ax_props)
859
- # Set hitmap colors for non-data elements
860
- ax.set_facecolor(HITMAP_BACKGROUND_COLOR)
861
- for spine in ax.spines.values():
862
- spine.set_color(HITMAP_AXES_COLOR)
863
- ax.tick_params(colors=HITMAP_AXES_COLOR, labelcolor=HITMAP_AXES_COLOR)
864
- ax.xaxis.label.set_color(HITMAP_AXES_COLOR)
865
- ax.yaxis.label.set_color(HITMAP_AXES_COLOR)
866
- ax.title.set_color(HITMAP_AXES_COLOR)
867
-
868
- fig.patch.set_facecolor(HITMAP_BACKGROUND_COLOR)
869
-
870
- # Save hitmap PNG with same bbox_inches='tight'
871
- hitmap_path = tmp_path / "plot_hitmap.png"
872
- fig.savefig(hitmap_path, dpi=dpi, bbox_inches='tight', format='png', facecolor=HITMAP_BACKGROUND_COLOR)
873
-
874
- # Optimize hitmap PNG size using zlib compression
875
- try:
876
- hitmap_img = PILImage.open(hitmap_path).convert('RGB')
877
- hitmap_img.save(hitmap_path, format='PNG', optimize=True, compress_level=9)
878
- except Exception:
879
- pass # Keep original if optimization fails
880
-
881
- # Save hitmap SVG with same bbox_inches='tight'
882
- hitmap_svg_path = tmp_path / "plot_hitmap.svg"
883
- fig.savefig(hitmap_svg_path, bbox_inches='tight', format='svg')
884
-
885
- # Restore original colors (data elements)
886
- restore_original_colors(original_props)
887
-
888
- # Restore original figure and axes colors
889
- fig.patch.set_facecolor(original_fig_facecolor)
890
- for i, ax in enumerate(fig.axes):
891
- ax.set_facecolor(original_ax_facecolors[i])
892
- if i < len(original_ax_props):
893
- props = original_ax_props[i]
894
- for spine_name, color in props['spine_colors'].items():
895
- ax.spines[spine_name].set_edgecolor(color)
896
- ax.xaxis.label.set_color(props['xlabel_color'])
897
- ax.yaxis.label.set_color(props['ylabel_color'])
898
- ax.title.set_color(props['title_color'])
899
-
900
- # Now apply auto-crop to BOTH PNG and hitmap with same parameters
901
- try:
902
- from scitex.plt.utils._crop import crop
903
-
904
- # Crop PNG and get crop coordinates
905
- _, crop_offset = crop(
906
- str(png_path),
907
- output_path=str(png_path),
908
- overwrite=True,
909
- margin=12, # ~1mm at 300 DPI
910
- verbose=False,
911
- return_offset=True,
912
- )
913
- crop_box = (crop_offset['left'], crop_offset['upper'],
914
- crop_offset['right'], crop_offset['lower'])
915
-
916
- # Apply SAME crop to hitmap PNG
917
- crop(
918
- str(hitmap_path),
919
- output_path=str(hitmap_path),
920
- overwrite=True,
921
- crop_box=crop_box,
922
- verbose=False,
923
- )
924
- except Exception as e:
925
- crop_box = None
926
- logger.debug(f"Crop failed: {e}")
927
-
928
- # Validate sizes match
929
- with PILImage.open(png_path) as png_img, PILImage.open(hitmap_path) as hm_img:
930
- if png_img.size != hm_img.size:
931
- logger.warning(f"Size mismatch: PNG={png_img.size}, Hitmap={hm_img.size}")
932
-
933
- with open(png_path, 'rb') as f:
934
- bundle_data['png'] = f.read()
935
-
936
- with open(hitmap_path, 'rb') as f:
937
- bundle_data['hitmap_png'] = f.read()
938
-
939
- with open(svg_path, 'rb') as f:
940
- bundle_data['svg'] = f.read()
941
-
942
- with open(hitmap_svg_path, 'rb') as f:
943
- bundle_data['hitmap_svg'] = f.read()
944
-
945
- with open(pdf_path, 'rb') as f:
946
- bundle_data['pdf'] = f.read()
947
-
948
- # Add hit_regions to spec
949
- try:
950
- path_data = extract_path_data(fig)
951
-
952
- spec['hit_regions'] = {
953
- 'strategy': 'hybrid',
954
- 'hit_map': f'{basename}_hitmap.png',
955
- 'hit_map_svg': f'{basename}_hitmap.svg',
956
- 'color_map': {str(k): v for k, v in color_map.items()},
957
- 'groups': groups, # Logical groups (histogram, bar_series, etc.)
958
- 'path_data': path_data,
959
- }
960
-
961
- if crop_box is not None:
962
- spec['hit_regions']['crop_box'] = {
963
- 'left': int(crop_box[0]),
964
- 'upper': int(crop_box[1]),
965
- 'right': int(crop_box[2]),
966
- 'lower': int(crop_box[3]),
967
- }
968
-
969
- # Extract selectable regions (bounding boxes for axis/annotation elements)
970
- # This complements hitmap color-based selection with bbox-based selection
971
- selectable_regions = extract_selectable_regions(fig)
972
- if selectable_regions and selectable_regions.get('axes'):
973
- spec['selectable_regions'] = selectable_regions
974
-
975
- except Exception as e:
976
- logger.debug(f"Hit regions spec failed: {e}")
977
253
 
978
- # Save the bundle
979
- save_bundle(bundle_data, p, bundle_type=BundleType.PLTZ, as_zip=as_zip)
254
+ def _handle_dry_run(spath, verbose):
255
+ """Handle dry run mode."""
256
+ if verbose:
257
+ try:
258
+ rel_path = _os.path.relpath(spath, _os.getcwd())
259
+ except ValueError:
260
+ rel_path = spath
261
+ logger.success(color_text(f"(dry run) Saved to: ./{rel_path}", c="yellow"))
980
262
 
981
263
 
982
264
  def _save(
@@ -993,741 +275,205 @@ def _save(
993
275
  json_schema="editable",
994
276
  **kwargs,
995
277
  ):
996
- # Don't use object's own save method - use consistent handlers
997
- # This ensures all saves go through the same pipeline and get
998
- # the yellow confirmation message
999
-
1000
- # Get file extension
278
+ """Core dispatcher for saving objects to various formats."""
1001
279
  ext = _os.path.splitext(spath)[1].lower()
1002
280
 
1003
- # Handle .canvas directories (special case - path ends with .canvas)
1004
- if spath.endswith(".canvas"):
1005
- save_canvas(obj, spath, **kwargs)
1006
- return
1007
-
1008
- # Handle bundle formats (.figz, .pltz, .statsz and their .d variants)
1009
- # These use special naming: file.figz (ZIP) or file.figz.d (directory)
1010
- # Note: .figz defaults to ZIP (as_zip=True), .pltz/.statsz default to directory
1011
- bundle_extensions = (".figz", ".pltz", ".statsz")
1012
- for bext in bundle_extensions:
1013
- if spath.endswith(bext) or spath.endswith(f"{bext}.d"):
1014
- # Remove as_zip from kwargs if present to avoid duplicate
1015
- bundle_kwargs = {k: v for k, v in kwargs.items() if k != 'as_zip'}
1016
- as_zip = kwargs.get('as_zip', not spath.endswith(".d"))
1017
- if bext == ".figz":
1018
- import scitex.fig as sfig
1019
- # figz defaults to ZIP, so always pass as_zip explicitly
1020
- sfig.save_figz(obj, spath, as_zip=as_zip, **bundle_kwargs)
1021
- elif bext == ".pltz":
1022
- _save_pltz_bundle(obj, spath, as_zip=as_zip, **bundle_kwargs)
1023
- elif bext == ".statsz":
1024
- import scitex.stats as sstats
1025
- sstats.save_statsz(obj, spath, as_zip=as_zip, **bundle_kwargs)
1026
-
1027
- # Log "Saved to:" for bundle formats (consistent with other formats)
1028
- # For bundles, determine the actual saved path (zip or directory)
1029
- bundle_path = spath if as_zip else f"{spath}.d" if not spath.endswith(".d") else spath
1030
-
1031
- if verbose and _os.path.exists(bundle_path):
1032
- file_size = getsize(bundle_path)
1033
- file_size = readable_bytes(file_size)
1034
- try:
1035
- rel_path = _os.path.relpath(bundle_path, _os.getcwd())
1036
- except ValueError:
1037
- rel_path = bundle_path
1038
- logger.success(f"Saved to: ./{rel_path} ({file_size})")
1039
-
1040
- # Handle symlinks for bundle formats (consistent with other formats)
1041
- if symlink_from_cwd and _os.path.exists(bundle_path):
1042
- # Create symlink from cwd to bundle path
1043
- bundle_basename = _os.path.basename(bundle_path)
1044
- bundle_cwd = _os.path.join(_os.getcwd(), bundle_basename)
1045
- _symlink(bundle_path, bundle_cwd, symlink_from_cwd, verbose)
1046
-
1047
- if symlink_to and _os.path.exists(bundle_path):
1048
- _symlink_to(bundle_path, symlink_to, verbose)
1049
-
281
+ # Check if this is a matplotlib figure being saved to FTS bundle format
282
+ # FTS bundles use .zip (archive) or no extension (directory)
283
+ if _is_matplotlib_figure(obj):
284
+ # Save as FTS bundle if:
285
+ # 1. Path ends with .zip (create ZIP bundle)
286
+ # 2. Path has no extension and doesn't match other formats (create directory bundle)
287
+ if ext == ".zip" or (ext == "" and not spath.endswith("/")):
288
+ # Check if explicitly requesting FTS bundle or just .zip
289
+ # Pop as_zip from kwargs to avoid duplicate parameter error
290
+ as_zip = kwargs.pop("as_zip", ext == ".zip")
291
+ _save_fts_bundle(
292
+ obj, spath, as_zip, verbose, symlink_from_cwd, symlink_to, **kwargs
293
+ )
1050
294
  return
1051
295
 
1052
- # Try dispatch dictionary first for O(1) lookup
296
+ # Dispatch to format handlers
1053
297
  if ext in _FILE_HANDLERS:
1054
- # Check if handler needs special parameters
1055
- if ext in [
1056
- ".png",
1057
- ".jpg",
1058
- ".jpeg",
1059
- ".gif",
1060
- ".tiff",
1061
- ".tif",
1062
- ".svg",
1063
- ".pdf",
1064
- ]:
1065
- _FILE_HANDLERS[ext](
1066
- obj,
1067
- spath,
1068
- verbose=verbose,
1069
- no_csv=no_csv,
1070
- symlink_from_cwd=symlink_from_cwd,
1071
- symlink_to=symlink_to,
1072
- dry_run=dry_run,
1073
- auto_crop=auto_crop,
1074
- crop_margin_mm=crop_margin_mm,
1075
- metadata_extra=metadata_extra,
1076
- json_schema=json_schema,
1077
- **kwargs,
1078
- )
1079
- elif ext in [".hdf5", ".h5", ".zarr"]:
1080
- # HDF5 and Zarr files may need special 'key' parameter
1081
- _FILE_HANDLERS[ext](obj, spath, **kwargs)
1082
- else:
1083
- _FILE_HANDLERS[ext](obj, spath, **kwargs)
1084
- # csv - special case as it doesn't have a dot prefix in dispatch
298
+ _dispatch_handler(
299
+ ext,
300
+ obj,
301
+ spath,
302
+ verbose,
303
+ no_csv,
304
+ symlink_from_cwd,
305
+ symlink_to,
306
+ dry_run,
307
+ auto_crop,
308
+ crop_margin_mm,
309
+ metadata_extra,
310
+ json_schema,
311
+ kwargs,
312
+ )
1085
313
  elif spath.endswith(".csv"):
1086
314
  save_csv(obj, spath, **kwargs)
1087
- # Check for special extension cases not in dispatch
1088
315
  elif spath.endswith(".pkl.gz"):
1089
316
  save_pickle_compressed(obj, spath, **kwargs)
1090
317
  else:
1091
318
  logger.warning(f"Unsupported file format. {spath} was not saved.")
1092
-
1093
- if verbose:
1094
- if _os.path.exists(spath):
1095
- file_size = getsize(spath)
1096
- file_size = readable_bytes(file_size)
1097
- # Get relative path from current working directory
1098
- try:
1099
- rel_path = _os.path.relpath(spath, _os.getcwd())
1100
- except ValueError:
1101
- rel_path = spath
1102
-
1103
- logger.success(f"Saved to: ./{rel_path} ({file_size})")
1104
-
1105
-
1106
- def _save_separate_legends(obj, spath, symlink_from_cwd=False, dry_run=False, **kwargs):
1107
- """Save separate legend files if ax.legend('separate') was used."""
1108
- if dry_run:
1109
- return
1110
-
1111
- import matplotlib.figure
1112
- import matplotlib.pyplot as plt
1113
-
1114
- # Get the matplotlib figure object
1115
- fig = None
1116
- if isinstance(obj, matplotlib.figure.Figure):
1117
- fig = obj
1118
- elif hasattr(obj, "_fig_mpl"):
1119
- fig = obj._fig_mpl
1120
- elif hasattr(obj, "figure"):
1121
- if isinstance(obj.figure, matplotlib.figure.Figure):
1122
- fig = obj.figure
1123
- elif hasattr(obj.figure, "_fig_mpl"):
1124
- fig = obj.figure._fig_mpl
1125
-
1126
- if fig is None:
1127
319
  return
1128
320
 
1129
- # Check if there are separate legend parameters stored
1130
- if not hasattr(fig, "_separate_legend_params"):
1131
- return
321
+ if verbose and _os.path.exists(spath):
322
+ file_size = readable_bytes(getsize(spath))
323
+ try:
324
+ rel_path = _os.path.relpath(spath, _os.getcwd())
325
+ except ValueError:
326
+ rel_path = spath
327
+ logger.success(f"Saved to: ./{rel_path} ({file_size})")
1132
328
 
1133
- # Save each legend as a separate file
1134
- base_path = _os.path.splitext(spath)[0]
1135
- ext = _os.path.splitext(spath)[1]
1136
-
1137
- for legend_params in fig._separate_legend_params:
1138
- # Create a new figure for the legend
1139
- legend_fig = plt.figure(figsize=legend_params["figsize"])
1140
- legend_ax = legend_fig.add_subplot(111)
1141
-
1142
- # Create the legend
1143
- legend = legend_ax.legend(
1144
- legend_params["handles"],
1145
- legend_params["labels"],
1146
- loc="center",
1147
- frameon=legend_params["frameon"],
1148
- fancybox=legend_params["fancybox"],
1149
- shadow=legend_params["shadow"],
1150
- **legend_params["kwargs"],
1151
- )
1152
329
 
1153
- # Remove axes
1154
- legend_ax.axis("off")
330
+ def _is_matplotlib_figure(obj):
331
+ """Check if object is a matplotlib figure or a wrapped figure.
1155
332
 
1156
- # Adjust layout to fit the legend
1157
- legend_fig.tight_layout()
333
+ Handles both raw matplotlib.figure.Figure and SciTeX FigWrapper objects.
334
+ """
335
+ try:
336
+ import matplotlib.figure
1158
337
 
1159
- # Save the legend figure
1160
- legend_filename = f"{base_path}_{legend_params['axis_id']}_legend{ext}"
1161
- save_image(legend_fig, legend_filename, **kwargs)
338
+ # Direct matplotlib figure
339
+ if isinstance(obj, matplotlib.figure.Figure):
340
+ return True
1162
341
 
1163
- # Close the legend figure to free memory
1164
- plt.close(legend_fig)
342
+ # Wrapped figure (e.g., FigWrapper from scitex.plt)
343
+ if hasattr(obj, "figure") and isinstance(obj.figure, matplotlib.figure.Figure):
344
+ return True
1165
345
 
1166
- if not dry_run and _os.path.exists(legend_filename):
1167
- file_size = getsize(legend_filename)
1168
- file_size = readable_bytes(file_size)
1169
- print(
1170
- color_text(
1171
- f"\nSaved legend to: {legend_filename} ({file_size})",
1172
- c="yellow",
1173
- )
1174
- )
346
+ return False
347
+ except ImportError:
348
+ return False
1175
349
 
1176
350
 
1177
- def _handle_image_with_csv(
1178
- obj,
1179
- spath,
1180
- verbose=False,
1181
- no_csv=False,
1182
- symlink_from_cwd=False,
1183
- dry_run=False,
1184
- symlink_to=None,
1185
- auto_crop=True,
1186
- crop_margin_mm=1.0,
1187
- metadata_extra=None,
1188
- json_schema="editable",
1189
- **kwargs,
351
+ def _save_fts_bundle(
352
+ obj, spath, as_zip, verbose, symlink_from_cwd, symlink_to_path, **kwargs
1190
353
  ):
1191
- """Handle image file saving with optional CSV export and auto-cropping."""
1192
- if dry_run:
1193
- return
354
+ """Save matplotlib figure as FTS bundle (.zip or directory).
1194
355
 
1195
- # Auto-collect metadata from scitex figures if not explicitly provided
1196
- collected_metadata = None
1197
- if "metadata" not in kwargs or kwargs["metadata"] is None:
1198
- try:
1199
- # Check if this is a matplotlib figure or scitex wrapper
1200
- import matplotlib.figure
1201
-
1202
- fig_mpl = None
1203
- if isinstance(obj, matplotlib.figure.Figure):
1204
- fig_mpl = obj
1205
- elif hasattr(obj, "_fig_mpl"): # FigWrapper
1206
- fig_mpl = obj._fig_mpl
1207
- elif hasattr(obj, "figure") and isinstance(
1208
- obj.figure, matplotlib.figure.Figure
1209
- ):
1210
- fig_mpl = obj.figure
1211
-
1212
- # If we have a figure, try to collect metadata
1213
- if fig_mpl is not None:
1214
- # Get axes from scitex wrapper if available (for multi-axes support)
1215
- # Priority: FigWrapper.axes (AxesWrapper) > mpl axes with _scitex_wrapper > mpl axes
1216
- ax = None
1217
-
1218
- # First try to get AxesWrapper from FigWrapper (obj)
1219
- if hasattr(obj, "axes"):
1220
- # obj is FigWrapper, get its axes (could be AxisWrapper or AxesWrapper)
1221
- ax = obj.axes
1222
- elif hasattr(fig_mpl, "axes") and len(fig_mpl.axes) > 0:
1223
- mpl_ax = fig_mpl.axes[0]
1224
- # Try to get scitex wrapper which has history for recipe schema
1225
- if hasattr(mpl_ax, '_scitex_wrapper'):
1226
- ax = mpl_ax._scitex_wrapper
1227
- else:
1228
- ax = mpl_ax
1229
-
1230
- # Collect metadata using scitex's metadata collector
1231
- try:
1232
- if json_schema == "editable":
1233
- from scitex.plt.utils.metadata import export_editable_figure
1234
- auto_metadata = export_editable_figure(fig_mpl)
1235
- elif json_schema == "recipe":
1236
- from scitex.plt.utils import collect_recipe_metadata
1237
- auto_metadata = collect_recipe_metadata(
1238
- fig_mpl, ax,
1239
- auto_crop=auto_crop,
1240
- crop_margin_mm=crop_margin_mm,
1241
- )
1242
- else:
1243
- from scitex.plt.utils import collect_figure_metadata
1244
- auto_metadata = collect_figure_metadata(fig_mpl, ax)
1245
-
1246
- if auto_metadata:
1247
- kwargs["metadata"] = auto_metadata
1248
- collected_metadata = auto_metadata # Save for JSON export
1249
- if verbose:
1250
- schema_names = {"editable": "editable v0.3", "recipe": "recipe", "verbose": "verbose"}
1251
- schema_name = schema_names.get(json_schema, json_schema)
1252
- logger.info(f" • Auto-collected metadata ({schema_name} schema)")
1253
- except ImportError:
1254
- pass # collect_figure_metadata not available
1255
- except Exception as e:
1256
- if verbose:
1257
- logger.warning(f"Could not auto-collect metadata: {e}")
1258
- except Exception:
1259
- pass # Silently continue if auto-collection fails
1260
- else:
1261
- # Use explicitly provided metadata
1262
- collected_metadata = kwargs.get("metadata")
356
+ Delegates to scitex.fts.from_matplotlib as the single source of truth
357
+ for bundle structure (canonical/artifacts/payload/children).
1263
358
 
1264
- # Merge metadata_extra with collected_metadata
1265
- if metadata_extra is not None and collected_metadata is not None:
1266
- # Deep merge: metadata_extra takes precedence
1267
- import copy
1268
-
1269
- collected_metadata = copy.deepcopy(collected_metadata)
359
+ When figrecipe is available and enabled on the figure, also saves
360
+ recipe.yaml for reproducibility.
361
+ """
362
+ # Get the actual matplotlib figure
363
+ import matplotlib.figure
1270
364
 
1271
- # If metadata_extra has plot_type and it doesn't exist in collected, add it
1272
- if "plot_type" in metadata_extra:
1273
- collected_metadata["plot_type"] = metadata_extra["plot_type"]
365
+ from scitex.fts import from_matplotlib
1274
366
 
1275
- # Merge style information
1276
- if "style" in metadata_extra:
1277
- collected_metadata["style"] = metadata_extra["style"]
367
+ from ._save_modules._figure_utils import get_figure_with_data
1278
368
 
1279
- # Merge any other fields from metadata_extra
1280
- for key, value in metadata_extra.items():
1281
- if key not in ["plot_type", "style"]:
1282
- collected_metadata[key] = value
369
+ if isinstance(obj, matplotlib.figure.Figure):
370
+ fig = obj
371
+ fig_wrapper = None
372
+ elif hasattr(obj, "figure") and isinstance(obj.figure, matplotlib.figure.Figure):
373
+ fig = obj.figure
374
+ fig_wrapper = obj # Keep wrapper for figrecipe access
375
+ else:
376
+ raise TypeError(f"Expected matplotlib figure, got {type(obj)}")
377
+
378
+ # Extract optional parameters
379
+ # Support both "csv_df" and "data" parameter names for user convenience
380
+ csv_df = kwargs.get("csv_df") or kwargs.get("data")
381
+ dpi = kwargs.get("dpi", 300)
382
+ name = kwargs.get("name") or Path(spath).stem
383
+
384
+ # Extract CSV data from scitex.plt tracking if available
385
+ scitex_source = get_figure_with_data(obj)
386
+ if csv_df is None and scitex_source is not None:
387
+ if hasattr(scitex_source, "export_as_csv"):
388
+ try:
389
+ csv_df = scitex_source.export_as_csv()
390
+ except Exception:
391
+ pass
1283
392
 
1284
- # Update kwargs metadata for image saving
1285
- kwargs["metadata"] = collected_metadata
393
+ # Delegate to FTS (single source of truth)
394
+ # Encoding is built from CSV columns directly for consistency
395
+ from_matplotlib(fig, spath, name=name, csv_df=csv_df, dpi=dpi)
1286
396
 
1287
- save_image(obj, spath, verbose=verbose, **kwargs)
397
+ # Save figrecipe recipe.yaml if available
398
+ try:
399
+ from scitex.bridge._figrecipe import _save_recipe_to_path
1288
400
 
1289
- # Auto-crop if requested (only for raster formats)
1290
- crop_offset = None
1291
- if auto_crop and not dry_run:
1292
- # Get file extension
1293
- ext = spath.lower()
401
+ bundle_path = Path(spath)
402
+ if bundle_path.suffix != ".zip": # Skip zip for now
403
+ _save_recipe_to_path(fig_wrapper or obj, bundle_path / "recipe.yaml")
404
+ except (ImportError, Exception):
405
+ pass # figrecipe is optional
1294
406
 
1295
- # Only crop raster formats (PNG, JPEG, TIFF)
1296
- # Skip vector formats (PDF, SVG) as they don't benefit from cropping
1297
- if ext.endswith((".png", ".jpg", ".jpeg", ".tiff", ".tif")):
1298
- try:
1299
- from scitex.plt.utils._crop import crop
1300
-
1301
- # Convert mm to pixels (assuming 300 DPI)
1302
- # 1mm at 300 DPI = 11.81 pixels 12 pixels
1303
- dpi = kwargs.get("dpi", 300)
1304
- margin_px = int(crop_margin_mm * dpi / 25.4) # 25.4mm per inch
1305
-
1306
- # Crop the saved image in place, get crop offset for metadata adjustment
1307
- _, crop_offset = crop(
1308
- spath,
1309
- output_path=spath,
1310
- margin=margin_px,
1311
- overwrite=True,
1312
- verbose=False,
1313
- return_offset=True,
1314
- )
1315
-
1316
- # Adjust axes_bbox_px in metadata to account for crop offset
1317
- if crop_offset and collected_metadata:
1318
- if "axes_bbox_px" in collected_metadata:
1319
- bbox = collected_metadata["axes_bbox_px"]
1320
- # Subtract crop offset from all coordinates
1321
- # left/upper is where the crop started
1322
- left_offset = crop_offset["left"]
1323
- upper_offset = crop_offset["upper"]
1324
- bbox["x0"] = bbox.get("x0", 0) - left_offset
1325
- bbox["x1"] = bbox.get("x1", 0) - left_offset
1326
- bbox["y0"] = bbox.get("y0", 0) - upper_offset
1327
- bbox["y1"] = bbox.get("y1", 0) - upper_offset
1328
- # Update width/height to match new image size
1329
- # (bbox width/height shouldn't change, but figure size does)
1330
-
1331
- # Also update figure size in metadata
1332
- if "figure" in collected_metadata:
1333
- fig_meta = collected_metadata["figure"]
1334
- if "size_px" in fig_meta:
1335
- fig_meta["size_px"] = [
1336
- crop_offset["new_width"],
1337
- crop_offset["new_height"],
1338
- ]
1339
- if "dimensions" in collected_metadata:
1340
- dim_meta = collected_metadata["dimensions"]
1341
- if "figure_size_px" in dim_meta:
1342
- dim_meta["figure_size_px"] = [
1343
- crop_offset["new_width"],
1344
- crop_offset["new_height"],
1345
- ]
1346
-
1347
- if verbose:
1348
- logger.info(
1349
- f" • Auto-cropped with {crop_margin_mm}mm margin ({margin_px}px at {dpi} DPI)"
1350
- )
1351
-
1352
- except Exception as e:
1353
- logger.warning(f"Auto-crop failed: {e}. Image saved without cropping.")
1354
-
1355
- # Handle separate legend saving
1356
- _save_separate_legends(
1357
- obj,
1358
- spath,
1359
- symlink_from_cwd=symlink_from_cwd,
1360
- dry_run=dry_run,
1361
- **kwargs,
1362
- )
407
+ bundle_path = spath
408
+ if verbose and _os.path.exists(bundle_path):
409
+ file_size = readable_bytes(getsize(bundle_path))
410
+ try:
411
+ rel_path = _os.path.relpath(bundle_path, _os.getcwd())
412
+ except ValueError:
413
+ rel_path = bundle_path
414
+ logger.success(f"Saved to: ./{rel_path} ({file_size})")
1363
415
 
1364
- if not no_csv:
1365
- ext = _os.path.splitext(spath)[1].lower()
1366
- ext_wo_dot = ext.replace(".", "")
416
+ if symlink_from_cwd and _os.path.exists(bundle_path):
417
+ bundle_basename = _os.path.basename(bundle_path)
418
+ bundle_cwd = _os.path.join(_os.getcwd(), bundle_basename)
419
+ _symlink(bundle_path, bundle_cwd, symlink_from_cwd, verbose)
1367
420
 
1368
- # Check if the path contains an image extension directory (e.g., ./png/, ./jpg/)
1369
- # If so, save CSV in a parallel ./csv/ directory
1370
- image_extensions = ["png", "jpg", "jpeg", "gif", "tiff", "tif", "svg", "pdf"]
1371
- parent_dir = _os.path.dirname(spath)
1372
- parent_name = _os.path.basename(parent_dir)
1373
- filename_without_ext = _os.path.splitext(_os.path.basename(spath))[0]
421
+ if symlink_to_path and _os.path.exists(bundle_path):
422
+ _symlink_to(bundle_path, symlink_to_path, verbose)
1374
423
 
1375
- csv_path = None # Initialize to avoid UnboundLocalError when CSV export is skipped
1376
- try:
1377
- # Get the figure object that may contain plot data
1378
- fig_obj = _get_figure_with_data(obj)
1379
-
1380
- if fig_obj is not None:
1381
- # Save regular CSV if export method exists
1382
- if hasattr(fig_obj, "export_as_csv"):
1383
- csv_data = fig_obj.export_as_csv()
1384
- if csv_data is not None and not csv_data.empty:
1385
- # Determine CSV path based on parent directory name
1386
- if parent_name.lower() in image_extensions:
1387
- # Parent directory is named after an image extension (e.g., png/)
1388
- # Create parallel csv/ directory
1389
- grandparent_dir = _os.path.dirname(parent_dir)
1390
- csv_dir = _os.path.join(grandparent_dir, "csv")
1391
- csv_path = _os.path.join(
1392
- csv_dir, filename_without_ext + ".csv"
1393
- )
1394
- else:
1395
- # Save CSV in same directory as image
1396
- csv_path = _os.path.splitext(spath)[0] + ".csv"
1397
-
1398
- # Ensure parent directory exists
1399
- _os.makedirs(_os.path.dirname(csv_path), exist_ok=True)
1400
- # Save directly using _save to avoid path doubling
1401
- # Don't pass image-specific kwargs to CSV save
1402
- _save(
1403
- csv_data,
1404
- csv_path,
1405
- verbose=True,
1406
- symlink_from_cwd=False, # Will handle symlink manually
1407
- dry_run=dry_run,
1408
- no_csv=True,
1409
- )
1410
-
1411
- # Update metadata with actual CSV info (after export)
1412
- # This ensures column names match exactly, including any
1413
- # deduplication suffixes added by pandas
1414
- if collected_metadata is not None:
1415
- try:
1416
- from scitex.plt.utils._collect_figure_metadata import (
1417
- _compute_csv_hash,
1418
- )
1419
-
1420
- # Ensure data section exists
1421
- if "data" not in collected_metadata:
1422
- collected_metadata["data"] = {}
1423
-
1424
- # Get actual column names from exported DataFrame
1425
- actual_columns = list(csv_data.columns)
1426
-
1427
- # Update data section with csv_path (relative to JSON)
1428
- # Since JSON and CSV are in the same or parallel directories,
1429
- # use just the filename for simplicity
1430
- collected_metadata["data"]["csv_path"] = _os.path.basename(csv_path)
1431
-
1432
- # Update columns to use flat list of actual columns
1433
- collected_metadata["data"]["columns_actual"] = actual_columns
1434
-
1435
- # Compute hash of actual CSV data
1436
- collected_metadata["data"]["csv_hash"] = _compute_csv_hash(
1437
- csv_data
1438
- )
1439
- except Exception:
1440
- pass # Silently continue if update fails
1441
-
1442
- # Create symlink_to for CSV if it was specified for the image
1443
- if symlink_to:
1444
- # Apply same directory transformation for symlink
1445
- symlink_parent_dir = _os.path.dirname(symlink_to)
1446
- symlink_parent_name = _os.path.basename(symlink_parent_dir)
1447
- symlink_filename_without_ext = _os.path.splitext(
1448
- _os.path.basename(symlink_to)
1449
- )[0]
1450
-
1451
- if symlink_parent_name.lower() in image_extensions:
1452
- symlink_grandparent_dir = _os.path.dirname(
1453
- symlink_parent_dir
1454
- )
1455
- csv_symlink_to = _os.path.join(
1456
- symlink_grandparent_dir,
1457
- "csv",
1458
- symlink_filename_without_ext + ".csv",
1459
- )
1460
- else:
1461
- csv_symlink_to = (
1462
- _os.path.splitext(symlink_to)[0] + ".csv"
1463
- )
1464
-
1465
- _symlink_to(csv_path, csv_symlink_to, True)
1466
-
1467
- # Create symlink for CSV manually if needed
1468
- if symlink_from_cwd:
1469
- # Get the relative path from the original specified path
1470
- # This preserves the directory structure for the symlink
1471
- import inspect
1472
-
1473
- frame_info = inspect.stack()
1474
- # Find the original specified_path from the parent save() call
1475
- for frame in frame_info:
1476
- if "specified_path" in frame.frame.f_locals:
1477
- original_path = frame.frame.f_locals[
1478
- "specified_path"
1479
- ]
1480
- if isinstance(original_path, str):
1481
- # Apply same directory transformation for symlink
1482
- orig_parent_dir = _os.path.dirname(
1483
- original_path
1484
- )
1485
- orig_parent_name = _os.path.basename(
1486
- orig_parent_dir
1487
- )
1488
- orig_filename_without_ext = _os.path.splitext(
1489
- _os.path.basename(original_path)
1490
- )[0]
1491
-
1492
- if orig_parent_name.lower() in image_extensions:
1493
- orig_grandparent_dir = _os.path.dirname(
1494
- orig_parent_dir
1495
- )
1496
- csv_relative = _os.path.join(
1497
- orig_grandparent_dir,
1498
- "csv",
1499
- orig_filename_without_ext + ".csv",
1500
- )
1501
- else:
1502
- csv_relative = original_path.replace(
1503
- _os.path.splitext(original_path)[1],
1504
- ".csv",
1505
- )
1506
-
1507
- csv_cwd = _os.path.join(
1508
- _os.getcwd(), csv_relative
1509
- )
1510
- _symlink(csv_path, csv_cwd, True, True)
1511
- break
1512
- else:
1513
- # Fallback to basename if we can't find the original path
1514
- csv_cwd = (
1515
- _os.getcwd() + "/" + _os.path.basename(csv_path)
1516
- )
1517
- _symlink(csv_path, csv_cwd, True, True)
1518
-
1519
- # Save SigmaPlot CSV if method exists
1520
- if hasattr(fig_obj, "export_as_csv_for_sigmaplot"):
1521
- sigmaplot_data = fig_obj.export_as_csv_for_sigmaplot()
1522
- if sigmaplot_data is not None and not sigmaplot_data.empty:
1523
- # Determine SigmaPlot CSV path based on parent directory name
1524
- if parent_name.lower() in image_extensions:
1525
- grandparent_dir = _os.path.dirname(parent_dir)
1526
- csv_dir = _os.path.join(grandparent_dir, "csv")
1527
- csv_sigmaplot_path = _os.path.join(
1528
- csv_dir, filename_without_ext + "_for_sigmaplot.csv"
1529
- )
1530
- else:
1531
- csv_sigmaplot_path = spath.replace(
1532
- ext_wo_dot, "csv"
1533
- ).replace(".csv", "_for_sigmaplot.csv")
1534
-
1535
- # Ensure parent directory exists
1536
- _os.makedirs(
1537
- _os.path.dirname(csv_sigmaplot_path), exist_ok=True
1538
- )
1539
- # Save directly using _save to avoid path doubling
1540
- # Don't pass image-specific kwargs to CSV save
1541
- _save(
1542
- sigmaplot_data,
1543
- csv_sigmaplot_path,
1544
- verbose=True,
1545
- symlink_from_cwd=False, # Will handle symlink manually
1546
- dry_run=dry_run,
1547
- no_csv=True,
1548
- )
1549
-
1550
- # Create symlink_to for SigmaPlot CSV if it was specified for the image
1551
- if symlink_to:
1552
- symlink_parent_dir = _os.path.dirname(symlink_to)
1553
- symlink_parent_name = _os.path.basename(symlink_parent_dir)
1554
- symlink_filename_without_ext = _os.path.splitext(
1555
- _os.path.basename(symlink_to)
1556
- )[0]
1557
-
1558
- if symlink_parent_name.lower() in image_extensions:
1559
- symlink_grandparent_dir = _os.path.dirname(
1560
- symlink_parent_dir
1561
- )
1562
- csv_sigmaplot_symlink_to = _os.path.join(
1563
- symlink_grandparent_dir,
1564
- "csv",
1565
- symlink_filename_without_ext + "_for_sigmaplot.csv",
1566
- )
1567
- else:
1568
- csv_sigmaplot_symlink_to = (
1569
- _os.path.splitext(symlink_to)[0]
1570
- + "_for_sigmaplot.csv"
1571
- )
1572
-
1573
- _symlink_to(
1574
- csv_sigmaplot_path,
1575
- csv_sigmaplot_symlink_to,
1576
- True,
1577
- )
1578
-
1579
- # Create symlink for SigmaPlot CSV manually if needed
1580
- if symlink_from_cwd:
1581
- csv_cwd = (
1582
- _os.getcwd()
1583
- + "/"
1584
- + _os.path.basename(csv_sigmaplot_path)
1585
- )
1586
- _symlink(csv_sigmaplot_path, csv_cwd, True, True)
1587
- except Exception as e:
1588
- logger.warning(f"CSV export failed: {e}")
1589
-
1590
- # Save metadata as JSON if collected
1591
- if collected_metadata is not None and not dry_run:
1592
- try:
1593
- # Check if the path contains an image extension directory (e.g., ./png/, ./jpg/)
1594
- # If so, save JSON in a parallel ./json/ directory
1595
- # Example: ./path/to/output/png/fig.png -> ./path/to/output/json/fig.json
1596
- # Example: ./path/to/output/fig.png -> ./path/to/output/fig.json (same dir)
1597
- image_extensions = [
1598
- "png",
1599
- "jpg",
1600
- "jpeg",
1601
- "gif",
1602
- "tiff",
1603
- "tif",
1604
- "svg",
1605
- "pdf",
1606
- ]
1607
- parent_dir = _os.path.dirname(spath)
1608
- parent_name = _os.path.basename(parent_dir)
1609
- filename_without_ext = _os.path.splitext(_os.path.basename(spath))[0]
1610
-
1611
- if parent_name.lower() in image_extensions:
1612
- # Parent directory is named after an image extension (e.g., png/)
1613
- # Create parallel json/ directory
1614
- grandparent_dir = _os.path.dirname(parent_dir)
1615
- json_dir = _os.path.join(grandparent_dir, "json")
1616
- json_path = _os.path.join(json_dir, filename_without_ext + ".json")
1617
- else:
1618
- # Save JSON in same directory as image
1619
- json_path = _os.path.splitext(spath)[0] + ".json"
1620
-
1621
- # Ensure parent directory exists
1622
- _os.makedirs(_os.path.dirname(json_path), exist_ok=True)
1623
-
1624
- # Save metadata as JSON
1625
- _save(
1626
- collected_metadata,
1627
- json_path,
1628
- verbose=True,
1629
- symlink_from_cwd=False, # Will handle symlink manually
1630
- dry_run=dry_run,
1631
- no_csv=True,
1632
- )
1633
424
 
1634
- # Verify CSV/JSON consistency (data_ref must match columns_actual)
1635
- # Only check for verbose schema - recipe/editable schemas use different data_ref structure
1636
- if csv_path and not dry_run and json_schema == "verbose":
1637
- from scitex.plt.utils._collect_figure_metadata import (
1638
- assert_csv_json_consistency,
1639
- )
1640
- assert_csv_json_consistency(csv_path, json_path)
1641
-
1642
- # Create symlink_to for JSON if it was specified for the image
1643
- if symlink_to:
1644
- # Apply same directory transformation for symlink
1645
- symlink_parent_dir = _os.path.dirname(symlink_to)
1646
- symlink_parent_name = _os.path.basename(symlink_parent_dir)
1647
- symlink_filename_without_ext = _os.path.splitext(
1648
- _os.path.basename(symlink_to)
1649
- )[0]
1650
-
1651
- if symlink_parent_name.lower() in image_extensions:
1652
- symlink_grandparent_dir = _os.path.dirname(symlink_parent_dir)
1653
- json_symlink_to = _os.path.join(
1654
- symlink_grandparent_dir,
1655
- "json",
1656
- symlink_filename_without_ext + ".json",
1657
- )
1658
- else:
1659
- json_symlink_to = _os.path.splitext(symlink_to)[0] + ".json"
1660
-
1661
- _symlink_to(json_path, json_symlink_to, True)
1662
-
1663
- # Create symlink for JSON manually if needed
1664
- if symlink_from_cwd:
1665
- # Get the relative path from the original specified path
1666
- # This preserves the directory structure for the symlink
1667
- import inspect
1668
-
1669
- frame_info = inspect.stack()
1670
- # Find the original specified_path from the parent save() call
1671
- for frame in frame_info:
1672
- if "specified_path" in frame.frame.f_locals:
1673
- original_path = frame.frame.f_locals["specified_path"]
1674
- if isinstance(original_path, str):
1675
- # Apply same directory transformation for symlink
1676
- orig_parent_dir = _os.path.dirname(original_path)
1677
- orig_parent_name = _os.path.basename(orig_parent_dir)
1678
- orig_filename_without_ext = _os.path.splitext(
1679
- _os.path.basename(original_path)
1680
- )[0]
1681
-
1682
- if orig_parent_name.lower() in image_extensions:
1683
- orig_grandparent_dir = _os.path.dirname(orig_parent_dir)
1684
- json_relative = _os.path.join(
1685
- orig_grandparent_dir,
1686
- "json",
1687
- orig_filename_without_ext + ".json",
1688
- )
1689
- else:
1690
- json_relative = original_path.replace(
1691
- _os.path.splitext(original_path)[1],
1692
- ".json",
1693
- )
1694
-
1695
- json_cwd = _os.path.join(_os.getcwd(), json_relative)
1696
- _symlink(json_path, json_cwd, True, True)
1697
- break
1698
- else:
1699
- # Fallback to basename if we can't find the original path
1700
- json_cwd = _os.getcwd() + "/" + _os.path.basename(json_path)
1701
- _symlink(json_path, json_cwd, True, True)
1702
-
1703
- except AssertionError:
1704
- # Re-raise assertion errors - these are validation failures that should stop execution
1705
- raise
1706
- except Exception as e:
1707
- logger.warning(f"JSON metadata export failed: {e}")
425
+ def _dispatch_handler(
426
+ ext,
427
+ obj,
428
+ spath,
429
+ verbose,
430
+ no_csv,
431
+ symlink_from_cwd,
432
+ symlink_to_path,
433
+ dry_run,
434
+ auto_crop,
435
+ crop_margin_mm,
436
+ metadata_extra,
437
+ json_schema,
438
+ kwargs,
439
+ ):
440
+ """Dispatch to the appropriate file handler."""
441
+ image_exts = [".png", ".jpg", ".jpeg", ".gif", ".tiff", ".tif", ".svg", ".pdf"]
442
+ if ext in image_exts:
443
+ _handle_image_with_csv(
444
+ obj,
445
+ spath,
446
+ verbose=verbose,
447
+ no_csv=no_csv,
448
+ symlink_from_cwd=symlink_from_cwd,
449
+ symlink_to_path=symlink_to_path,
450
+ dry_run=dry_run,
451
+ auto_crop=auto_crop,
452
+ crop_margin_mm=crop_margin_mm,
453
+ metadata_extra=metadata_extra,
454
+ json_schema=json_schema,
455
+ **kwargs,
456
+ )
457
+ elif ext in [".hdf5", ".h5", ".zarr"]:
458
+ _FILE_HANDLERS[ext](obj, spath, **kwargs)
459
+ else:
460
+ _FILE_HANDLERS[ext](obj, spath, **kwargs)
1708
461
 
1709
462
 
1710
463
  # Dispatch dictionary for O(1) file format lookup
1711
464
  _FILE_HANDLERS = {
1712
- # Canvas directory format (scitex.fig)
1713
- ".canvas": save_canvas,
1714
- # Excel formats
1715
465
  ".xlsx": save_excel,
1716
466
  ".xls": save_excel,
1717
- # NumPy formats
1718
467
  ".npy": save_npy,
1719
468
  ".npz": save_npz,
1720
- # Pickle formats
1721
469
  ".pkl": save_pickle,
1722
470
  ".pickle": save_pickle,
1723
471
  ".pkl.gz": save_pickle_compressed,
1724
- # Other binary formats
1725
472
  ".joblib": save_joblib,
1726
473
  ".pth": save_torch,
1727
474
  ".pt": save_torch,
1728
475
  ".mat": save_matlab,
1729
476
  ".cbm": save_catboost,
1730
- # Text formats
1731
477
  ".json": save_json,
1732
478
  ".yaml": save_yaml,
1733
479
  ".yml": save_yaml,
@@ -1737,23 +483,20 @@ _FILE_HANDLERS = {
1737
483
  ".css": save_text,
1738
484
  ".js": save_text,
1739
485
  ".tex": save_tex,
1740
- # Bibliography
1741
486
  ".bib": save_bibtex,
1742
- # Data formats
1743
487
  ".html": save_html,
1744
488
  ".hdf5": save_hdf5,
1745
489
  ".h5": save_hdf5,
1746
490
  ".zarr": save_zarr,
1747
- # Media formats
1748
491
  ".mp4": save_mp4,
1749
- ".png": _handle_image_with_csv,
1750
- ".jpg": _handle_image_with_csv,
1751
- ".jpeg": _handle_image_with_csv,
1752
- ".gif": _handle_image_with_csv,
1753
- ".tiff": _handle_image_with_csv,
1754
- ".tif": _handle_image_with_csv,
1755
- ".svg": _handle_image_with_csv,
1756
- ".pdf": _handle_image_with_csv,
492
+ ".png": handle_image_with_csv,
493
+ ".jpg": handle_image_with_csv,
494
+ ".jpeg": handle_image_with_csv,
495
+ ".gif": handle_image_with_csv,
496
+ ".tiff": handle_image_with_csv,
497
+ ".tif": handle_image_with_csv,
498
+ ".svg": handle_image_with_csv,
499
+ ".pdf": handle_image_with_csv,
1757
500
  }
1758
501
 
1759
502
  # EOF