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
@@ -0,0 +1,1339 @@
1
+ #!/usr/bin/env python3
2
+ # File: ./src/scitex/vis/editor/flask_editor/bbox.py
3
+ """Bounding box extraction for figure elements.
4
+
5
+ Updated to integrate Schema v0.3 geometry extraction for shape-based hit testing.
6
+ """
7
+
8
+ from typing import Any, Dict
9
+
10
+ # Try to import schema v0.3 geometry extraction
11
+ try:
12
+ from scitex.plt.utils.metadata._geometry_extraction import (
13
+ extract_axes_bbox_px,
14
+ extract_bar_group_geometry,
15
+ extract_line_geometry,
16
+ extract_polygon_geometry,
17
+ extract_scatter_geometry,
18
+ )
19
+
20
+ GEOMETRY_V03_AVAILABLE = True
21
+ except ImportError:
22
+ GEOMETRY_V03_AVAILABLE = False
23
+
24
+
25
+ def extract_bboxes(
26
+ fig, ax, renderer, img_width: int, img_height: int
27
+ ) -> Dict[str, Any]:
28
+ """Extract bounding boxes for all figure elements (single-axis)."""
29
+ from matplotlib.transforms import Bbox
30
+
31
+ # Get figure tight bbox in inches
32
+ fig_bbox = fig.get_tightbbox(renderer)
33
+ tight_x0 = fig_bbox.x0
34
+ tight_y0 = fig_bbox.y0
35
+ tight_width = fig_bbox.width
36
+ tight_height = fig_bbox.height
37
+
38
+ # bbox_inches='tight' adds pad_inches (default 0.1) around the tight bbox
39
+ pad_inches = 0.1
40
+ saved_width_inches = tight_width + 2 * pad_inches
41
+ saved_height_inches = tight_height + 2 * pad_inches
42
+
43
+ # Scale factors for converting inches to pixels
44
+ scale_x = img_width / saved_width_inches
45
+ scale_y = img_height / saved_height_inches
46
+
47
+ bboxes = {}
48
+
49
+ def get_element_bbox(element, name):
50
+ """Get element bbox in image pixel coordinates."""
51
+ try:
52
+ bbox = element.get_window_extent(renderer)
53
+
54
+ elem_x0_inches = bbox.x0 / fig.dpi
55
+ elem_x1_inches = bbox.x1 / fig.dpi
56
+ elem_y0_inches = bbox.y0 / fig.dpi
57
+ elem_y1_inches = bbox.y1 / fig.dpi
58
+
59
+ x0_rel = elem_x0_inches - tight_x0 + pad_inches
60
+ x1_rel = elem_x1_inches - tight_x0 + pad_inches
61
+ y0_rel = saved_height_inches - (elem_y1_inches - tight_y0 + pad_inches)
62
+ y1_rel = saved_height_inches - (elem_y0_inches - tight_y0 + pad_inches)
63
+
64
+ bboxes[name] = {
65
+ "x0": max(0, int(x0_rel * scale_x)),
66
+ "y0": max(0, int(y0_rel * scale_y)),
67
+ "x1": min(img_width, int(x1_rel * scale_x)),
68
+ "y1": min(img_height, int(y1_rel * scale_y)),
69
+ "label": name.replace("_", " ").title(),
70
+ }
71
+ except Exception as e:
72
+ print(f"Error getting bbox for {name}: {e}")
73
+
74
+ def bbox_to_img_coords(bbox):
75
+ """Convert matplotlib bbox to image pixel coordinates."""
76
+ x0_inches = bbox.x0 / fig.dpi
77
+ y0_inches = bbox.y0 / fig.dpi
78
+ x1_inches = bbox.x1 / fig.dpi
79
+ y1_inches = bbox.y1 / fig.dpi
80
+ x0_rel = x0_inches - tight_x0 + pad_inches
81
+ y0_rel = y0_inches - tight_y0 + pad_inches
82
+ x1_rel = x1_inches - tight_x0 + pad_inches
83
+ y1_rel = y1_inches - tight_y0 + pad_inches
84
+ return {
85
+ "x0": int(x0_rel * scale_x),
86
+ "y0": int((saved_height_inches - y1_rel) * scale_y),
87
+ "x1": int(x1_rel * scale_x),
88
+ "y1": int((saved_height_inches - y0_rel) * scale_y),
89
+ }
90
+
91
+ # Get bboxes for title, labels
92
+ # Use ax_00_ prefix for consistency with geometry_px.json format
93
+ ax_prefix = "ax_00_"
94
+ if ax.title.get_text():
95
+ get_element_bbox(ax.title, f"{ax_prefix}title")
96
+ if ax.xaxis.label.get_text():
97
+ get_element_bbox(ax.xaxis.label, f"{ax_prefix}xlabel")
98
+ if ax.yaxis.label.get_text():
99
+ get_element_bbox(ax.yaxis.label, f"{ax_prefix}ylabel")
100
+
101
+ # Get axis bboxes
102
+ _extract_axis_bboxes(ax, renderer, bboxes, bbox_to_img_coords, Bbox, ax_prefix)
103
+
104
+ # Get legend bbox
105
+ legend = ax.get_legend()
106
+ if legend:
107
+ get_element_bbox(legend, f"{ax_prefix}legend")
108
+
109
+ # Get caption bbox (figure-level text)
110
+ for text_artist in fig.texts:
111
+ # Check if this is a caption (positioned below figure)
112
+ text_content = text_artist.get_text()
113
+ if text_content:
114
+ pos = text_artist.get_position()
115
+ # Caption is typically centered horizontally (x ~= 0.5) and below figure (y < 0.1)
116
+ if pos[1] < 0.1:
117
+ get_element_bbox(text_artist, f"{ax_prefix}caption")
118
+ break # Only one caption expected
119
+
120
+ # Get trace (line) bboxes
121
+ _extract_trace_bboxes(
122
+ ax,
123
+ fig,
124
+ renderer,
125
+ bboxes,
126
+ get_element_bbox,
127
+ tight_x0,
128
+ tight_y0,
129
+ saved_height_inches,
130
+ scale_x,
131
+ scale_y,
132
+ pad_inches,
133
+ )
134
+
135
+ # Add schema v0.3 metadata if available
136
+ if GEOMETRY_V03_AVAILABLE:
137
+ axes_bbox = extract_axes_bbox_px(ax, fig)
138
+ bboxes["_meta"] = {
139
+ "schema_version": "0.3.0",
140
+ "axes_bbox_px": axes_bbox,
141
+ "geometry_available": True,
142
+ }
143
+
144
+ return bboxes
145
+
146
+
147
+ def extract_bboxes_multi(
148
+ fig, axes_map: Dict[str, Any], renderer, img_width: int, img_height: int
149
+ ) -> Dict[str, Any]:
150
+ """Extract bounding boxes for all elements in a multi-axis figure.
151
+
152
+ Args:
153
+ fig: Matplotlib figure
154
+ axes_map: Dict mapping axis IDs (e.g., 'ax_00') to matplotlib Axes objects
155
+ renderer: Matplotlib renderer
156
+ img_width: Image width in pixels
157
+ img_height: Image height in pixels
158
+
159
+ Returns:
160
+ Dict with bboxes keyed by "{ax_id}_{element_type}" (e.g., "ax_00_xlabel")
161
+ """
162
+ from matplotlib.transforms import Bbox
163
+
164
+ # Get figure tight bbox in inches
165
+ fig_bbox = fig.get_tightbbox(renderer)
166
+ tight_x0 = fig_bbox.x0
167
+ tight_y0 = fig_bbox.y0
168
+ tight_width = fig_bbox.width
169
+ tight_height = fig_bbox.height
170
+
171
+ # bbox_inches='tight' adds pad_inches (default 0.1) around the tight bbox
172
+ pad_inches = 0.1
173
+ saved_width_inches = tight_width + 2 * pad_inches
174
+ saved_height_inches = tight_height + 2 * pad_inches
175
+
176
+ # Scale factors for converting inches to pixels
177
+ scale_x = img_width / saved_width_inches
178
+ scale_y = img_height / saved_height_inches
179
+
180
+ bboxes = {}
181
+
182
+ def get_element_bbox(element, name, ax_id, current_ax=None):
183
+ """Get element bbox in image pixel coordinates."""
184
+ import numpy as np
185
+
186
+ full_name = f"{ax_id}_{name}"
187
+ try:
188
+ bbox = element.get_window_extent(renderer)
189
+
190
+ # Check for invalid bbox (infinity or NaN)
191
+ if not (
192
+ np.isfinite(bbox.x0)
193
+ and np.isfinite(bbox.x1)
194
+ and np.isfinite(bbox.y0)
195
+ and np.isfinite(bbox.y1)
196
+ ):
197
+ # Try to get bbox from data for scatter/collection elements
198
+ if hasattr(element, "get_offsets") and current_ax is not None:
199
+ offsets = element.get_offsets()
200
+ if len(offsets) > 0 and np.isfinite(offsets).all():
201
+ # Use axis transform to get display coordinates
202
+ display_coords = current_ax.transData.transform(offsets)
203
+ x0 = display_coords[:, 0].min()
204
+ x1 = display_coords[:, 0].max()
205
+ y0 = display_coords[:, 1].min()
206
+ y1 = display_coords[:, 1].max()
207
+ if np.isfinite([x0, x1, y0, y1]).all():
208
+ from matplotlib.transforms import Bbox
209
+
210
+ bbox = Bbox.from_extents(x0, y0, x1, y1)
211
+ else:
212
+ return # Skip this element
213
+ else:
214
+ return # Skip this element
215
+ else:
216
+ return # Skip this element
217
+
218
+ elem_x0_inches = bbox.x0 / fig.dpi
219
+ elem_x1_inches = bbox.x1 / fig.dpi
220
+ elem_y0_inches = bbox.y0 / fig.dpi
221
+ elem_y1_inches = bbox.y1 / fig.dpi
222
+
223
+ x0_rel = elem_x0_inches - tight_x0 + pad_inches
224
+ x1_rel = elem_x1_inches - tight_x0 + pad_inches
225
+ y0_rel = saved_height_inches - (elem_y1_inches - tight_y0 + pad_inches)
226
+ y1_rel = saved_height_inches - (elem_y0_inches - tight_y0 + pad_inches)
227
+
228
+ # Clamp values to avoid overflow
229
+ x0_px = max(0, min(img_width, int(x0_rel * scale_x)))
230
+ y0_px = max(0, min(img_height, int(y0_rel * scale_y)))
231
+ x1_px = max(0, min(img_width, int(x1_rel * scale_x)))
232
+ y1_px = max(0, min(img_height, int(y1_rel * scale_y)))
233
+
234
+ bboxes[full_name] = {
235
+ "x0": x0_px,
236
+ "y0": y0_px,
237
+ "x1": x1_px,
238
+ "y1": y1_px,
239
+ "label": f"{ax_id}: {name.replace('_', ' ').title()}",
240
+ "ax_id": ax_id,
241
+ }
242
+ except Exception as e:
243
+ print(f"Error getting bbox for {full_name}: {e}")
244
+
245
+ def bbox_to_img_coords(bbox):
246
+ """Convert matplotlib bbox to image pixel coordinates."""
247
+ x0_inches = bbox.x0 / fig.dpi
248
+ y0_inches = bbox.y0 / fig.dpi
249
+ x1_inches = bbox.x1 / fig.dpi
250
+ y1_inches = bbox.y1 / fig.dpi
251
+ x0_rel = x0_inches - tight_x0 + pad_inches
252
+ y0_rel = y0_inches - tight_y0 + pad_inches
253
+ x1_rel = x1_inches - tight_x0 + pad_inches
254
+ y1_rel = y1_inches - tight_y0 + pad_inches
255
+ return {
256
+ "x0": int(x0_rel * scale_x),
257
+ "y0": int((saved_height_inches - y1_rel) * scale_y),
258
+ "x1": int(x1_rel * scale_x),
259
+ "y1": int((saved_height_inches - y0_rel) * scale_y),
260
+ }
261
+
262
+ # Extract bboxes for each axis
263
+ for ax_id, ax in axes_map.items():
264
+ # Get axes bounding box (the entire panel area)
265
+ try:
266
+ ax_bbox = ax.get_window_extent(renderer)
267
+ coords = bbox_to_img_coords(ax_bbox)
268
+ # Extract actual title/labels from the axes
269
+ title_text = ax.title.get_text() if ax.title else ""
270
+ xlabel_text = ax.xaxis.label.get_text() if ax.xaxis.label else ""
271
+ ylabel_text = ax.yaxis.label.get_text() if ax.yaxis.label else ""
272
+ bboxes[f"{ax_id}_panel"] = {
273
+ **coords,
274
+ "label": f"Panel {ax_id}",
275
+ "ax_id": ax_id,
276
+ "is_panel": True,
277
+ "title": title_text,
278
+ "xlabel": xlabel_text,
279
+ "ylabel": ylabel_text,
280
+ }
281
+ except Exception as e:
282
+ print(f"Error getting panel bbox for {ax_id}: {e}")
283
+
284
+ # Get bboxes for title, labels
285
+ if ax.title.get_text():
286
+ get_element_bbox(ax.title, "title", ax_id, ax)
287
+ if ax.xaxis.label.get_text():
288
+ get_element_bbox(ax.xaxis.label, "xlabel", ax_id, ax)
289
+ if ax.yaxis.label.get_text():
290
+ get_element_bbox(ax.yaxis.label, "ylabel", ax_id, ax)
291
+
292
+ # Get X-axis bbox (spine + ticks + ticklabels)
293
+ _extract_axis_bboxes_for_axis(
294
+ ax, ax_id, renderer, bboxes, bbox_to_img_coords, Bbox
295
+ )
296
+
297
+ # Get legend bbox
298
+ legend = ax.get_legend()
299
+ if legend:
300
+ get_element_bbox(legend, "legend", ax_id, ax)
301
+ # Add element_type for drag detection
302
+ if f"{ax_id}_legend" in bboxes:
303
+ bboxes[f"{ax_id}_legend"]["element_type"] = "legend"
304
+ bboxes[f"{ax_id}_legend"]["draggable"] = True
305
+
306
+ # Get panel letter (text annotations like A, B, C)
307
+ import re
308
+
309
+ panel_letter_pattern = re.compile(r"^[A-Z]\.?$|^\([A-Za-z]\)$")
310
+ for idx, text_artist in enumerate(ax.texts):
311
+ text_content = text_artist.get_text().strip()
312
+ if text_content and panel_letter_pattern.match(text_content):
313
+ name = f"panel_letter_{text_content.replace('.', '').replace('(', '').replace(')', '')}"
314
+ get_element_bbox(text_artist, name, ax_id, ax)
315
+ full_name = f"{ax_id}_{name}"
316
+ if full_name in bboxes:
317
+ bboxes[full_name]["element_type"] = "panel_letter"
318
+ bboxes[full_name]["draggable"] = True
319
+ bboxes[full_name]["text"] = text_content
320
+ # Get position in axes coordinates (0-1)
321
+ pos = text_artist.get_position()
322
+ transform = text_artist.get_transform()
323
+ if transform == ax.transAxes:
324
+ bboxes[full_name]["axes_position"] = {"x": pos[0], "y": pos[1]}
325
+
326
+ # Get trace (line) bboxes
327
+ _extract_trace_bboxes_for_axis(
328
+ ax,
329
+ ax_id,
330
+ fig,
331
+ renderer,
332
+ bboxes,
333
+ get_element_bbox,
334
+ tight_x0,
335
+ tight_y0,
336
+ saved_height_inches,
337
+ scale_x,
338
+ scale_y,
339
+ pad_inches,
340
+ )
341
+
342
+ # Get caption bbox (figure-level text)
343
+ # This is outside the per-axis loop since caption is a figure-level element
344
+ for text_artist in fig.texts:
345
+ text_content = text_artist.get_text()
346
+ if text_content:
347
+ pos = text_artist.get_position()
348
+ # Caption is typically centered horizontally (x ~= 0.5) and below figure (y < 0.1)
349
+ if pos[1] < 0.1:
350
+ try:
351
+ bbox = text_artist.get_window_extent(renderer)
352
+ x0_inches = bbox.x0 / fig.dpi
353
+ x1_inches = bbox.x1 / fig.dpi
354
+ y0_inches = bbox.y0 / fig.dpi
355
+ y1_inches = bbox.y1 / fig.dpi
356
+ x0_rel = x0_inches - tight_x0 + pad_inches
357
+ x1_rel = x1_inches - tight_x0 + pad_inches
358
+ y0_rel = saved_height_inches - (y1_inches - tight_y0 + pad_inches)
359
+ y1_rel = saved_height_inches - (y0_inches - tight_y0 + pad_inches)
360
+ bboxes["caption"] = {
361
+ "x0": max(0, int(x0_rel * scale_x)),
362
+ "y0": max(0, int(y0_rel * scale_y)),
363
+ "x1": min(img_width, int(x1_rel * scale_x)),
364
+ "y1": min(img_height, int(y1_rel * scale_y)),
365
+ "label": "Caption",
366
+ }
367
+ except Exception as e:
368
+ print(f"Error getting caption bbox: {e}")
369
+ break # Only one caption expected
370
+
371
+ # Add schema v0.3 metadata if available
372
+ if GEOMETRY_V03_AVAILABLE:
373
+ axes_bboxes = {}
374
+ for ax_id, ax in axes_map.items():
375
+ axes_bboxes[ax_id] = extract_axes_bbox_px(ax, fig)
376
+ bboxes["_meta"] = {
377
+ "schema_version": "0.3.0",
378
+ "axes": axes_bboxes,
379
+ "geometry_available": True,
380
+ }
381
+
382
+ return bboxes
383
+
384
+
385
+ def _extract_trace_bboxes_for_axis(
386
+ ax,
387
+ ax_id,
388
+ fig,
389
+ renderer,
390
+ bboxes,
391
+ get_element_bbox,
392
+ tight_x0,
393
+ tight_y0,
394
+ saved_height_inches,
395
+ scale_x,
396
+ scale_y,
397
+ pad_inches,
398
+ ):
399
+ """Extract bboxes for all data elements in a specific axis.
400
+
401
+ Handles:
402
+ - Lines (plot, errorbar lines)
403
+ - Scatter points (PathCollection)
404
+ - Fill areas (PolyCollection from fill_between)
405
+ - Bars (Rectangle patches)
406
+ """
407
+ import numpy as np
408
+
409
+ def coords_to_img_points(data_coords):
410
+ """Convert data coordinates to image pixel coordinates."""
411
+ if len(data_coords) == 0:
412
+ return []
413
+ transform = ax.transData
414
+ points_display = transform.transform(data_coords)
415
+ points_img = []
416
+ for px, py in points_display:
417
+ # Skip invalid points (NaN, infinity)
418
+ if not np.isfinite(px) or not np.isfinite(py):
419
+ continue
420
+ px_inches = px / fig.dpi
421
+ py_inches = py / fig.dpi
422
+ x_rel = px_inches - tight_x0 + pad_inches
423
+ y_rel = saved_height_inches - (py_inches - tight_y0 + pad_inches)
424
+ # Clamp to reasonable bounds to avoid overflow
425
+ x_img = max(-10000, min(10000, int(x_rel * scale_x)))
426
+ y_img = max(-10000, min(10000, int(y_rel * scale_y)))
427
+ points_img.append([x_img, y_img])
428
+ # Downsample if too many
429
+ if len(points_img) > 100:
430
+ step = len(points_img) // 100
431
+ points_img = points_img[::step]
432
+ return points_img
433
+
434
+ # 1. Extract lines (plot, errorbar lines, etc.)
435
+ line_idx = 0
436
+ for line in ax.get_lines():
437
+ try:
438
+ label = line.get_label()
439
+ # Include unlabeled lines but mark them appropriately
440
+ if label is None or label.startswith("_"):
441
+ label = None # Will use generic name
442
+
443
+ trace_name = f"trace_{line_idx}"
444
+ full_name = f"{ax_id}_{trace_name}"
445
+ get_element_bbox(line, trace_name, ax_id, ax)
446
+
447
+ if full_name in bboxes:
448
+ bboxes[full_name]["label"] = f"{ax_id}: {label or f'Line {line_idx}'}"
449
+ bboxes[full_name]["trace_idx"] = line_idx
450
+ bboxes[full_name]["element_type"] = "line"
451
+
452
+ xdata, ydata = line.get_xdata(), line.get_ydata()
453
+ if len(xdata) > 0:
454
+ bboxes[full_name]["points"] = coords_to_img_points(
455
+ list(zip(xdata, ydata))
456
+ )
457
+
458
+ # Add schema v0.3 geometry_px if available
459
+ if GEOMETRY_V03_AVAILABLE:
460
+ try:
461
+ geom = extract_line_geometry(line, ax, fig)
462
+ bboxes[full_name]["geometry_px"] = geom
463
+ except Exception:
464
+ pass # Fall back to legacy points
465
+
466
+ line_idx += 1
467
+ except Exception as e:
468
+ print(f"Error getting line bbox for {ax_id}: {e}")
469
+
470
+ # 2. Extract collections (scatter, fill_between, etc.)
471
+ coll_idx = 0
472
+ for coll in ax.collections:
473
+ try:
474
+ label = coll.get_label()
475
+ if label is None or label.startswith("_"):
476
+ # Still extract unlabeled collections but with generic name
477
+ label = None
478
+
479
+ coll_type = type(coll).__name__
480
+ if coll_type == "PathCollection":
481
+ # Scatter points
482
+ element_name = f"scatter_{coll_idx}"
483
+ full_name = f"{ax_id}_{element_name}"
484
+ get_element_bbox(coll, element_name, ax_id, ax)
485
+
486
+ if full_name in bboxes:
487
+ bboxes[full_name][
488
+ "label"
489
+ ] = f"{ax_id}: {label or f'Scatter {coll_idx}'}"
490
+ bboxes[full_name]["element_type"] = "scatter"
491
+
492
+ # Get scatter point positions
493
+ offsets = coll.get_offsets()
494
+ if len(offsets) > 0:
495
+ bboxes[full_name]["points"] = coords_to_img_points(offsets)
496
+
497
+ # Add schema v0.3 geometry_px if available
498
+ if GEOMETRY_V03_AVAILABLE:
499
+ try:
500
+ geom = extract_scatter_geometry(coll, ax, fig)
501
+ bboxes[full_name]["geometry_px"] = geom
502
+ except Exception:
503
+ pass # Fall back to legacy points
504
+
505
+ elif coll_type in ("PolyCollection", "FillBetweenPolyCollection"):
506
+ # Fill areas (fill_between, etc.)
507
+ element_name = f"fill_{coll_idx}"
508
+ full_name = f"{ax_id}_{element_name}"
509
+ get_element_bbox(coll, element_name, ax_id, ax)
510
+
511
+ if full_name in bboxes:
512
+ bboxes[full_name][
513
+ "label"
514
+ ] = f"{ax_id}: {label or f'Fill {coll_idx}'}"
515
+ bboxes[full_name]["element_type"] = "fill"
516
+
517
+ # Add schema v0.3 geometry_px if available
518
+ if GEOMETRY_V03_AVAILABLE:
519
+ try:
520
+ geom = extract_polygon_geometry(coll, ax, fig)
521
+ bboxes[full_name]["geometry_px"] = geom
522
+ except Exception:
523
+ pass
524
+
525
+ coll_idx += 1
526
+ except Exception as e:
527
+ print(f"Error getting collection bbox for {ax_id}: {e}")
528
+
529
+ # 3. Extract patches (bars, rectangles, etc.)
530
+ patch_idx = 0
531
+ for patch in ax.patches:
532
+ try:
533
+ label = patch.get_label()
534
+ patch_type = type(patch).__name__
535
+
536
+ if patch_type == "Rectangle":
537
+ # Bar chart bars
538
+ element_name = f"bar_{patch_idx}"
539
+ full_name = f"{ax_id}_{element_name}"
540
+ get_element_bbox(patch, element_name, ax_id, ax)
541
+
542
+ if full_name in bboxes:
543
+ bboxes[full_name][
544
+ "label"
545
+ ] = f"{ax_id}: {label or f'Bar {patch_idx}'}"
546
+ bboxes[full_name]["element_type"] = "bar"
547
+
548
+ patch_idx += 1
549
+ except Exception as e:
550
+ print(f"Error getting patch bbox for {ax_id}: {e}")
551
+
552
+
553
+ def _extract_axis_bboxes_for_axis(
554
+ ax, ax_id, renderer, bboxes, bbox_to_img_coords, Bbox
555
+ ):
556
+ """Extract X and Y axis bboxes for a specific axis (multi-axis version)."""
557
+ try:
558
+ # X-axis: combine spine and tick labels into one bbox
559
+ x_axis_bboxes = []
560
+ for ticklabel in ax.xaxis.get_ticklabels():
561
+ if ticklabel.get_visible():
562
+ try:
563
+ tb = ticklabel.get_window_extent(renderer)
564
+ if tb.width > 0:
565
+ x_axis_bboxes.append(tb)
566
+ except Exception:
567
+ pass
568
+ for tick in ax.xaxis.get_major_ticks():
569
+ if tick.tick1line.get_visible():
570
+ try:
571
+ tb = tick.tick1line.get_window_extent(renderer)
572
+ if tb.width > 0 or tb.height > 0:
573
+ x_axis_bboxes.append(tb)
574
+ except Exception:
575
+ pass
576
+ spine_bbox = ax.spines["bottom"].get_window_extent(renderer)
577
+ if spine_bbox.width > 0:
578
+ if x_axis_bboxes:
579
+ tick_union = Bbox.union(x_axis_bboxes)
580
+ constrained_spine = Bbox.from_extents(
581
+ tick_union.x0, spine_bbox.y0, tick_union.x1, spine_bbox.y1
582
+ )
583
+ x_axis_bboxes.append(constrained_spine)
584
+ else:
585
+ x_axis_bboxes.append(spine_bbox)
586
+ if x_axis_bboxes:
587
+ combined = Bbox.union(x_axis_bboxes)
588
+ bboxes[f"{ax_id}_xaxis"] = bbox_to_img_coords(combined)
589
+ bboxes[f"{ax_id}_xaxis"]["label"] = f"{ax_id}: X Axis"
590
+ bboxes[f"{ax_id}_xaxis"]["ax_id"] = ax_id
591
+ bboxes[f"{ax_id}_xaxis"]["element_type"] = "xaxis"
592
+
593
+ # Y-axis: combine spine and tick labels into one bbox
594
+ y_axis_bboxes = []
595
+ for ticklabel in ax.yaxis.get_ticklabels():
596
+ if ticklabel.get_visible():
597
+ try:
598
+ tb = ticklabel.get_window_extent(renderer)
599
+ if tb.width > 0:
600
+ y_axis_bboxes.append(tb)
601
+ except Exception:
602
+ pass
603
+ for tick in ax.yaxis.get_major_ticks():
604
+ if tick.tick1line.get_visible():
605
+ try:
606
+ tb = tick.tick1line.get_window_extent(renderer)
607
+ if tb.width > 0 or tb.height > 0:
608
+ y_axis_bboxes.append(tb)
609
+ except Exception:
610
+ pass
611
+ spine_bbox = ax.spines["left"].get_window_extent(renderer)
612
+ if spine_bbox.height > 0:
613
+ if y_axis_bboxes:
614
+ tick_union = Bbox.union(y_axis_bboxes)
615
+ constrained_spine = Bbox.from_extents(
616
+ spine_bbox.x0, tick_union.y0, spine_bbox.x1, tick_union.y1
617
+ )
618
+ y_axis_bboxes.append(constrained_spine)
619
+ else:
620
+ y_axis_bboxes.append(spine_bbox)
621
+ if y_axis_bboxes:
622
+ combined = Bbox.union(y_axis_bboxes)
623
+ padded = Bbox.from_extents(
624
+ combined.x0 - 10, combined.y0 - 5, combined.x1 + 5, combined.y1 + 5
625
+ )
626
+ bboxes[f"{ax_id}_yaxis"] = bbox_to_img_coords(padded)
627
+ bboxes[f"{ax_id}_yaxis"]["label"] = f"{ax_id}: Y Axis"
628
+ bboxes[f"{ax_id}_yaxis"]["ax_id"] = ax_id
629
+ bboxes[f"{ax_id}_yaxis"]["element_type"] = "yaxis"
630
+
631
+ except Exception as e:
632
+ print(f"Error getting axis bboxes for {ax_id}: {e}")
633
+
634
+
635
+ def _extract_axis_bboxes(ax, renderer, bboxes, bbox_to_img_coords, Bbox, ax_prefix=""):
636
+ """Extract bboxes for X and Y axis elements.
637
+
638
+ Args:
639
+ ax: Matplotlib axis.
640
+ renderer: Figure renderer.
641
+ bboxes: Dict to store bboxes.
642
+ bbox_to_img_coords: Coordinate conversion function.
643
+ Bbox: Matplotlib Bbox class.
644
+ ax_prefix: Prefix for bbox names (e.g., "ax_00_").
645
+ """
646
+ try:
647
+ # X-axis: combine spine and tick labels into one bbox
648
+ x_axis_bboxes = []
649
+ for ticklabel in ax.xaxis.get_ticklabels():
650
+ if ticklabel.get_visible():
651
+ try:
652
+ tb = ticklabel.get_window_extent(renderer)
653
+ if tb.width > 0:
654
+ x_axis_bboxes.append(tb)
655
+ except Exception:
656
+ pass
657
+ for tick in ax.xaxis.get_major_ticks():
658
+ if tick.tick1line.get_visible():
659
+ try:
660
+ tb = tick.tick1line.get_window_extent(renderer)
661
+ if tb.width > 0 or tb.height > 0:
662
+ x_axis_bboxes.append(tb)
663
+ except Exception:
664
+ pass
665
+ spine_bbox = ax.spines["bottom"].get_window_extent(renderer)
666
+ if spine_bbox.width > 0:
667
+ if x_axis_bboxes:
668
+ tick_union = Bbox.union(x_axis_bboxes)
669
+ constrained_spine = Bbox.from_extents(
670
+ tick_union.x0, spine_bbox.y0, tick_union.x1, spine_bbox.y1
671
+ )
672
+ x_axis_bboxes.append(constrained_spine)
673
+ else:
674
+ x_axis_bboxes.append(spine_bbox)
675
+ if x_axis_bboxes:
676
+ combined = Bbox.union(x_axis_bboxes)
677
+ bboxes[f"{ax_prefix}xaxis_spine"] = bbox_to_img_coords(combined)
678
+ bboxes[f"{ax_prefix}xaxis_spine"]["label"] = "X Spine & Ticks"
679
+
680
+ # Y-axis: combine spine and tick labels into one bbox
681
+ y_axis_bboxes = []
682
+ for ticklabel in ax.yaxis.get_ticklabels():
683
+ if ticklabel.get_visible():
684
+ try:
685
+ tb = ticklabel.get_window_extent(renderer)
686
+ if tb.width > 0:
687
+ y_axis_bboxes.append(tb)
688
+ except Exception:
689
+ pass
690
+ for tick in ax.yaxis.get_major_ticks():
691
+ if tick.tick1line.get_visible():
692
+ try:
693
+ tb = tick.tick1line.get_window_extent(renderer)
694
+ if tb.width > 0 or tb.height > 0:
695
+ y_axis_bboxes.append(tb)
696
+ except Exception:
697
+ pass
698
+ spine_bbox = ax.spines["left"].get_window_extent(renderer)
699
+ if spine_bbox.height > 0:
700
+ if y_axis_bboxes:
701
+ tick_union = Bbox.union(y_axis_bboxes)
702
+ constrained_spine = Bbox.from_extents(
703
+ spine_bbox.x0, tick_union.y0, spine_bbox.x1, tick_union.y1
704
+ )
705
+ y_axis_bboxes.append(constrained_spine)
706
+ else:
707
+ y_axis_bboxes.append(spine_bbox)
708
+ if y_axis_bboxes:
709
+ combined = Bbox.union(y_axis_bboxes)
710
+ padded = Bbox.from_extents(
711
+ combined.x0 - 10, combined.y0 - 5, combined.x1 + 5, combined.y1 + 5
712
+ )
713
+ bboxes[f"{ax_prefix}yaxis_spine"] = bbox_to_img_coords(padded)
714
+ bboxes[f"{ax_prefix}yaxis_spine"]["label"] = "Y Spine & Ticks"
715
+
716
+ except Exception as e:
717
+ print(f"Error getting axis bboxes: {e}")
718
+
719
+
720
+ def _extract_trace_bboxes(
721
+ ax,
722
+ fig,
723
+ renderer,
724
+ bboxes,
725
+ get_element_bbox,
726
+ tight_x0,
727
+ tight_y0,
728
+ saved_height_inches,
729
+ scale_x,
730
+ scale_y,
731
+ pad_inches,
732
+ ):
733
+ """Extract bboxes for all data elements (lines, scatter, fill) with proximity detection."""
734
+ import numpy as np
735
+
736
+ def coords_to_img_points(data_coords):
737
+ """Convert data coordinates to image pixel coordinates."""
738
+ if len(data_coords) == 0:
739
+ return []
740
+ transform = ax.transData
741
+ points_display = transform.transform(data_coords)
742
+ points_img = []
743
+ for px, py in points_display:
744
+ if not np.isfinite(px) or not np.isfinite(py):
745
+ continue
746
+ px_inches = px / fig.dpi
747
+ py_inches = py / fig.dpi
748
+ x_rel = px_inches - tight_x0 + pad_inches
749
+ y_rel = saved_height_inches - (py_inches - tight_y0 + pad_inches)
750
+ x_img = max(-10000, min(10000, int(x_rel * scale_x)))
751
+ y_img = max(-10000, min(10000, int(y_rel * scale_y)))
752
+ points_img.append([x_img, y_img])
753
+ if len(points_img) > 100:
754
+ step = len(points_img) // 100
755
+ points_img = points_img[::step]
756
+ return points_img
757
+
758
+ # 1. Extract lines
759
+ for idx, line in enumerate(ax.get_lines()):
760
+ try:
761
+ label = line.get_label()
762
+ # Include unlabeled lines but mark them appropriately
763
+ if label is None or label.startswith("_"):
764
+ label = None # Will use generic name
765
+ get_element_bbox(line, f"trace_{idx}")
766
+ if f"trace_{idx}" in bboxes:
767
+ bboxes[f"trace_{idx}"]["label"] = label or f"Trace {idx}"
768
+ bboxes[f"trace_{idx}"]["trace_idx"] = idx
769
+ bboxes[f"trace_{idx}"]["element_type"] = "line"
770
+
771
+ xdata, ydata = line.get_xdata(), line.get_ydata()
772
+ if len(xdata) > 0:
773
+ bboxes[f"trace_{idx}"]["points"] = coords_to_img_points(
774
+ list(zip(xdata, ydata))
775
+ )
776
+
777
+ # Add schema v0.3 geometry_px if available
778
+ if GEOMETRY_V03_AVAILABLE:
779
+ try:
780
+ geom = extract_line_geometry(line, ax, fig)
781
+ bboxes[f"trace_{idx}"]["geometry_px"] = geom
782
+ except Exception:
783
+ pass
784
+ except Exception as e:
785
+ print(f"Error getting trace bbox: {e}")
786
+
787
+ # 2. Extract collections (scatter, fill_between)
788
+ coll_idx = 0
789
+ for coll in ax.collections:
790
+ try:
791
+ label = coll.get_label()
792
+ if label is None or label.startswith("_"):
793
+ label = None
794
+
795
+ coll_type = type(coll).__name__
796
+ if coll_type == "PathCollection":
797
+ # Scatter points
798
+ elem_key = f"scatter_{coll_idx}"
799
+ get_element_bbox(coll, elem_key)
800
+
801
+ # Initialize entry if bbox extraction failed but we have data
802
+ offsets = coll.get_offsets()
803
+ if elem_key not in bboxes and len(offsets) > 0:
804
+ # Create bbox from data coordinates as fallback
805
+ points_img = coords_to_img_points(offsets)
806
+ if points_img:
807
+ xs = [p[0] for p in points_img]
808
+ ys = [p[1] for p in points_img]
809
+ bboxes[elem_key] = {
810
+ "x0": min(xs) - 10,
811
+ "y0": min(ys) - 10,
812
+ "x1": max(xs) + 10,
813
+ "y1": max(ys) + 10,
814
+ }
815
+
816
+ if elem_key in bboxes:
817
+ bboxes[elem_key]["label"] = label or f"Scatter {coll_idx}"
818
+ bboxes[elem_key]["element_type"] = "scatter"
819
+
820
+ if len(offsets) > 0:
821
+ bboxes[elem_key]["points"] = coords_to_img_points(offsets)
822
+
823
+ # Add schema v0.3 geometry_px if available
824
+ if GEOMETRY_V03_AVAILABLE:
825
+ try:
826
+ geom = extract_scatter_geometry(coll, ax, fig)
827
+ bboxes[elem_key]["geometry_px"] = geom
828
+ except Exception:
829
+ pass
830
+
831
+ elif coll_type in ("PolyCollection", "FillBetweenPolyCollection"):
832
+ # Fill areas
833
+ get_element_bbox(coll, f"fill_{coll_idx}")
834
+ if f"fill_{coll_idx}" in bboxes:
835
+ bboxes[f"fill_{coll_idx}"]["label"] = label or f"Fill {coll_idx}"
836
+ bboxes[f"fill_{coll_idx}"]["element_type"] = "fill"
837
+
838
+ # Add schema v0.3 geometry_px if available
839
+ if GEOMETRY_V03_AVAILABLE:
840
+ try:
841
+ geom = extract_polygon_geometry(coll, ax, fig)
842
+ bboxes[f"fill_{coll_idx}"]["geometry_px"] = geom
843
+ except Exception:
844
+ pass
845
+
846
+ coll_idx += 1
847
+ except Exception as e:
848
+ print(f"Error getting collection bbox: {e}")
849
+
850
+
851
+ def extract_bboxes_from_metadata(
852
+ metadata: Dict[str, Any],
853
+ img_width: int,
854
+ img_height: int,
855
+ ) -> Dict[str, Any]:
856
+ """Extract bounding boxes from pre-computed metadata (without re-rendering).
857
+
858
+ This is used when loading actual PNGs from bundles instead of re-rendering.
859
+ Extracts bbox info from:
860
+ - hit_regions (if available from v0.3 schema)
861
+ - elements dict
862
+ - axes positions
863
+
864
+ Args:
865
+ metadata: JSON metadata from spec.json or panel JSON
866
+ img_width: Image width in pixels
867
+ img_height: Image height in pixels
868
+
869
+ Returns:
870
+ Dict with bboxes keyed by element name
871
+ """
872
+ bboxes = {}
873
+
874
+ # Check for pre-computed hit_regions (v0.3 schema)
875
+ hit_regions = metadata.get("hit_regions", {})
876
+ if hit_regions:
877
+ color_map = hit_regions.get("color_map", {})
878
+ for element_name, color in color_map.items():
879
+ # We don't have exact coords from color map, but we can create placeholder
880
+ bboxes[element_name] = {
881
+ "label": element_name.replace("_", " ").title(),
882
+ "element_type": _guess_element_type(element_name),
883
+ }
884
+
885
+ # Check for geometry_px in cache (v0.3 layered bundle)
886
+ geometry_px = metadata.get("geometry_px", {})
887
+ if geometry_px:
888
+ for element_name, geom in geometry_px.items():
889
+ if isinstance(geom, dict) and "bbox" in geom:
890
+ bbox = geom["bbox"]
891
+ bboxes[element_name] = {
892
+ "x0": bbox.get("x0", 0),
893
+ "y0": bbox.get("y0", 0),
894
+ "x1": bbox.get("x1", img_width),
895
+ "y1": bbox.get("y1", img_height),
896
+ "label": element_name.replace("_", " ").title(),
897
+ "element_type": _guess_element_type(element_name),
898
+ }
899
+ if "points" in geom:
900
+ bboxes[element_name]["points"] = geom["points"]
901
+
902
+ # Extract from elements dict if present
903
+ elements = metadata.get("elements", {})
904
+ if not isinstance(elements, dict):
905
+ elements = {}
906
+ for element_name, element_info in elements.items():
907
+ if not isinstance(element_info, dict):
908
+ continue
909
+ if element_name not in bboxes:
910
+ bboxes[element_name] = {
911
+ "label": element_info.get(
912
+ "label", element_name.replace("_", " ").title()
913
+ ),
914
+ "element_type": element_info.get(
915
+ "type", _guess_element_type(element_name)
916
+ ),
917
+ }
918
+
919
+ # Extract from axes (handle both dict and list formats)
920
+ axes = metadata.get("axes", [])
921
+ if isinstance(axes, list):
922
+ axes_list = axes
923
+ elif isinstance(axes, dict):
924
+ axes_list = list(axes.values())
925
+ else:
926
+ axes_list = []
927
+
928
+ for i, ax_spec in enumerate(axes_list):
929
+ if not isinstance(ax_spec, dict):
930
+ continue
931
+
932
+ ax_id = ax_spec.get("id", f"ax{i}")
933
+
934
+ # Panel bbox - check for "bbox" field (new format) or "position" (old format)
935
+ bbox_spec = ax_spec.get("bbox", {})
936
+ pos = ax_spec.get("position", [])
937
+
938
+ if bbox_spec and isinstance(bbox_spec, dict):
939
+ # New format: bbox with x0, y0, width, height in panel fraction
940
+ x0_frac = bbox_spec.get("x0", 0)
941
+ y0_frac = bbox_spec.get("y0", 0)
942
+ w_frac = bbox_spec.get("width", 1)
943
+ h_frac = bbox_spec.get("height", 1)
944
+ x0 = int(x0_frac * img_width)
945
+ y0 = int(y0_frac * img_height)
946
+ x1 = int((x0_frac + w_frac) * img_width)
947
+ y1 = int((y0_frac + h_frac) * img_height)
948
+ bboxes[f"{ax_id}_panel"] = {
949
+ "x0": x0,
950
+ "y0": y0,
951
+ "x1": x1,
952
+ "y1": y1,
953
+ "label": f"Panel {ax_id}",
954
+ "ax_id": ax_id,
955
+ "is_panel": True,
956
+ }
957
+ elif len(pos) >= 4:
958
+ # Old format: position is in figure fraction [x0, y0, width, height]
959
+ x0 = int(pos[0] * img_width)
960
+ y0 = int((1 - pos[1] - pos[3]) * img_height) # Flip Y
961
+ x1 = int((pos[0] + pos[2]) * img_width)
962
+ y1 = int((1 - pos[1]) * img_height) # Flip Y
963
+ bboxes[f"{ax_id}_panel"] = {
964
+ "x0": x0,
965
+ "y0": y0,
966
+ "x1": x1,
967
+ "y1": y1,
968
+ "label": f"Panel {ax_id}",
969
+ "ax_id": ax_id,
970
+ "is_panel": True,
971
+ }
972
+
973
+ # Title/labels from labels dict (new format) or xaxis/yaxis (old format)
974
+ labels = ax_spec.get("labels", {})
975
+ xaxis = ax_spec.get("xaxis", {})
976
+ yaxis = ax_spec.get("yaxis", {})
977
+
978
+ xlabel = labels.get("xlabel") or (
979
+ xaxis.get("label") if isinstance(xaxis, dict) else None
980
+ )
981
+ ylabel = labels.get("ylabel") or (
982
+ yaxis.get("label") if isinstance(yaxis, dict) else None
983
+ )
984
+ title = labels.get("title")
985
+
986
+ if xlabel:
987
+ bboxes[f"{ax_id}_xlabel"] = {
988
+ "label": f"{ax_id}: {xlabel}",
989
+ "element_type": "xlabel",
990
+ "ax_id": ax_id,
991
+ }
992
+ if ylabel:
993
+ bboxes[f"{ax_id}_ylabel"] = {
994
+ "label": f"{ax_id}: {ylabel}",
995
+ "element_type": "ylabel",
996
+ "ax_id": ax_id,
997
+ }
998
+ if title:
999
+ bboxes[f"{ax_id}_title"] = {
1000
+ "label": title,
1001
+ "element_type": "title",
1002
+ "ax_id": ax_id,
1003
+ }
1004
+
1005
+ # Extract from traces array (pltz spec format)
1006
+ traces = metadata.get("traces", [])
1007
+ if isinstance(traces, list):
1008
+ for i, trace in enumerate(traces):
1009
+ if not isinstance(trace, dict):
1010
+ continue
1011
+ trace_id = trace.get("id", f"trace_{i}")
1012
+ trace_type = trace.get("type", "line")
1013
+ trace_label = trace.get("label", f"Trace {i}")
1014
+ ax_idx = trace.get("axes_index", 0)
1015
+
1016
+ # Use axes bbox as fallback for trace bbox
1017
+ ax_panel_key = None
1018
+ for key in bboxes:
1019
+ if key.endswith("_panel") and bboxes[key].get("ax_id", "").endswith(
1020
+ str(ax_idx)
1021
+ ):
1022
+ ax_panel_key = key
1023
+ break
1024
+ if not ax_panel_key:
1025
+ # Find any panel bbox
1026
+ for key in bboxes:
1027
+ if key.endswith("_panel"):
1028
+ ax_panel_key = key
1029
+ break
1030
+
1031
+ trace_bbox = {
1032
+ "label": trace_label,
1033
+ "element_type": trace_type,
1034
+ "trace_idx": i,
1035
+ "ax_id": f"ax{ax_idx}",
1036
+ }
1037
+
1038
+ # Copy panel bbox coordinates if available
1039
+ if ax_panel_key and ax_panel_key in bboxes:
1040
+ panel = bboxes[ax_panel_key]
1041
+ trace_bbox["x0"] = panel.get("x0", 0)
1042
+ trace_bbox["y0"] = panel.get("y0", 0)
1043
+ trace_bbox["x1"] = panel.get("x1", img_width)
1044
+ trace_bbox["y1"] = panel.get("y1", img_height)
1045
+
1046
+ bboxes[f"trace_{i}"] = trace_bbox
1047
+
1048
+ # If no bboxes found, return minimal set
1049
+ if not bboxes:
1050
+ bboxes["panel"] = {
1051
+ "x0": 0,
1052
+ "y0": 0,
1053
+ "x1": img_width,
1054
+ "y1": img_height,
1055
+ "label": "Panel",
1056
+ "is_panel": True,
1057
+ }
1058
+
1059
+ return bboxes
1060
+
1061
+
1062
+ def _guess_element_type(name: str) -> str:
1063
+ """Guess element type from element name."""
1064
+ name_lower = name.lower()
1065
+ if "line" in name_lower or "trace" in name_lower:
1066
+ return "line"
1067
+ elif "scatter" in name_lower:
1068
+ return "scatter"
1069
+ elif "bar" in name_lower:
1070
+ return "bar"
1071
+ elif "fill" in name_lower:
1072
+ return "fill"
1073
+ elif "xlabel" in name_lower:
1074
+ return "xlabel"
1075
+ elif "ylabel" in name_lower:
1076
+ return "ylabel"
1077
+ elif "title" in name_lower:
1078
+ return "title"
1079
+ elif "legend" in name_lower:
1080
+ return "legend"
1081
+ elif "xaxis" in name_lower:
1082
+ return "xaxis"
1083
+ elif "yaxis" in name_lower:
1084
+ return "yaxis"
1085
+ elif "panel" in name_lower:
1086
+ return "panel"
1087
+ return "unknown"
1088
+
1089
+
1090
+ def extract_bboxes_from_geometry_px(
1091
+ geometry_data: Dict[str, Any],
1092
+ img_width: int,
1093
+ img_height: int,
1094
+ ) -> Dict[str, Any]:
1095
+ """Extract bounding boxes from geometry_px.json (cached pixel coordinates).
1096
+
1097
+ This provides precise pixel coordinates for interactive element selection.
1098
+
1099
+ Args:
1100
+ geometry_data: JSON data from geometry_px.json
1101
+ img_width: Actual image width in pixels
1102
+ img_height: Actual image height in pixels
1103
+
1104
+ Returns:
1105
+ Dict with bboxes keyed by element name
1106
+ """
1107
+ bboxes = {}
1108
+
1109
+ # Get figure dimensions from geometry to calculate scale
1110
+ figure_px = geometry_data.get("figure_px", [img_width, img_height])
1111
+ if isinstance(figure_px, list) and len(figure_px) >= 2:
1112
+ geom_width, geom_height = figure_px[0], figure_px[1]
1113
+ else:
1114
+ geom_width, geom_height = img_width, img_height
1115
+
1116
+ # Scale factor if image size differs from geometry
1117
+ scale_x = img_width / geom_width if geom_width > 0 else 1
1118
+ scale_y = img_height / geom_height if geom_height > 0 else 1
1119
+
1120
+ # Extract axes bboxes
1121
+ axes = geometry_data.get("axes", [])
1122
+ for i, ax in enumerate(axes):
1123
+ if not isinstance(ax, dict):
1124
+ continue
1125
+ ax_id = ax.get("id", f"ax{i}")
1126
+ bbox_px = ax.get("bbox_px", {})
1127
+ if bbox_px:
1128
+ x0 = float(bbox_px.get("x0", 0)) * scale_x
1129
+ y0 = float(bbox_px.get("y0", 0)) * scale_y
1130
+ w = float(bbox_px.get("width", 0)) * scale_x
1131
+ h = float(bbox_px.get("height", 0)) * scale_y
1132
+ bboxes[f"{ax_id}_panel"] = {
1133
+ "x0": int(x0),
1134
+ "y0": int(y0),
1135
+ "x1": int(x0 + w),
1136
+ "y1": int(y0 + h),
1137
+ "label": f"Axes {ax_id}",
1138
+ "ax_id": ax_id,
1139
+ "is_panel": True,
1140
+ }
1141
+
1142
+ # Helper to safely convert to int (handle inf/nan)
1143
+ def safe_int(val, default=0, max_val=10000):
1144
+ import math
1145
+
1146
+ if val is None or math.isinf(val) or math.isnan(val):
1147
+ return default
1148
+ return max(0, min(int(val), max_val))
1149
+
1150
+ # Extract artists (lines, scatter, bars, etc.)
1151
+ artists = geometry_data.get("artists", [])
1152
+ for i, artist in enumerate(artists):
1153
+ if not isinstance(artist, dict):
1154
+ continue
1155
+
1156
+ artist_id = artist.get("id", str(i))
1157
+ artist_type = artist.get("type", "unknown")
1158
+ artist_label = artist.get("label") or f"{artist_type}_{i}"
1159
+ axes_index = artist.get("axes_index", 0)
1160
+
1161
+ # Get bbox_px
1162
+ bbox_px = artist.get("bbox_px", {})
1163
+ if bbox_px:
1164
+ x0 = float(bbox_px.get("x0", 0)) * scale_x
1165
+ y0 = float(bbox_px.get("y0", 0)) * scale_y
1166
+ w = float(bbox_px.get("width", 0)) * scale_x
1167
+ h = float(bbox_px.get("height", 0)) * scale_y
1168
+
1169
+ artist_bbox = {
1170
+ "x0": safe_int(x0, 0, img_width),
1171
+ "y0": safe_int(y0, 0, img_height),
1172
+ "x1": safe_int(x0 + w, img_width, img_width),
1173
+ "y1": safe_int(y0 + h, img_height, img_height),
1174
+ "label": artist_label,
1175
+ "element_type": artist_type,
1176
+ "trace_idx": i,
1177
+ "ax_id": f"ax{axes_index}",
1178
+ }
1179
+
1180
+ # Get path_px for lines (for precise hover detection)
1181
+ path_px = artist.get("path_px", [])
1182
+ if path_px and len(path_px) > 0:
1183
+ import math
1184
+
1185
+ # Scale points to actual image coordinates, filter out inf/nan
1186
+ scaled_points = []
1187
+ for pt in path_px:
1188
+ if isinstance(pt, (list, tuple)) and len(pt) >= 2:
1189
+ px, py = pt[0] * scale_x, pt[1] * scale_y
1190
+ if not (
1191
+ math.isinf(px)
1192
+ or math.isinf(py)
1193
+ or math.isnan(px)
1194
+ or math.isnan(py)
1195
+ ):
1196
+ scaled_points.append([px, py])
1197
+ if scaled_points:
1198
+ artist_bbox["points"] = scaled_points
1199
+
1200
+ # Get scatter points if available
1201
+ scatter_px = artist.get("scatter_px", [])
1202
+ if scatter_px and len(scatter_px) > 0:
1203
+ import math
1204
+
1205
+ scaled_points = []
1206
+ for pt in scatter_px:
1207
+ if isinstance(pt, (list, tuple)) and len(pt) >= 2:
1208
+ px, py = pt[0] * scale_x, pt[1] * scale_y
1209
+ if not (
1210
+ math.isinf(px)
1211
+ or math.isinf(py)
1212
+ or math.isnan(px)
1213
+ or math.isnan(py)
1214
+ ):
1215
+ scaled_points.append([px, py])
1216
+ if scaled_points:
1217
+ artist_bbox["points"] = scaled_points
1218
+ artist_bbox["element_type"] = "scatter"
1219
+
1220
+ bboxes[f"trace_{i}"] = artist_bbox
1221
+
1222
+ # Extract from selectable_regions (title, xlabel, ylabel, xaxis, yaxis)
1223
+ selectable = geometry_data.get("selectable_regions", {})
1224
+ sel_axes = selectable.get("axes", [])
1225
+ for ax_data in sel_axes:
1226
+ if not isinstance(ax_data, dict):
1227
+ continue
1228
+ ax_idx = ax_data.get("index", 0)
1229
+ ax_id = f"ax{ax_idx}"
1230
+
1231
+ # Title
1232
+ title_data = ax_data.get("title", {})
1233
+ if title_data and "bbox_px" in title_data:
1234
+ bbox = title_data["bbox_px"]
1235
+ if isinstance(bbox, list) and len(bbox) >= 4:
1236
+ bboxes[f"{ax_id}_title"] = {
1237
+ "x0": safe_int(bbox[0] * scale_x, 0, img_width),
1238
+ "y0": safe_int(bbox[1] * scale_y, 0, img_height),
1239
+ "x1": safe_int(bbox[2] * scale_x, img_width, img_width),
1240
+ "y1": safe_int(bbox[3] * scale_y, img_height, img_height),
1241
+ "label": title_data.get("text", "Title"),
1242
+ "element_type": "title",
1243
+ "ax_id": ax_id,
1244
+ }
1245
+
1246
+ # X Label
1247
+ xlabel_data = ax_data.get("xlabel", {})
1248
+ if xlabel_data and "bbox_px" in xlabel_data:
1249
+ bbox = xlabel_data["bbox_px"]
1250
+ if isinstance(bbox, list) and len(bbox) >= 4:
1251
+ bboxes[f"{ax_id}_xlabel"] = {
1252
+ "x0": safe_int(bbox[0] * scale_x, 0, img_width),
1253
+ "y0": safe_int(bbox[1] * scale_y, 0, img_height),
1254
+ "x1": safe_int(bbox[2] * scale_x, img_width, img_width),
1255
+ "y1": safe_int(bbox[3] * scale_y, img_height, img_height),
1256
+ "label": xlabel_data.get("text", "X Label"),
1257
+ "element_type": "xlabel",
1258
+ "ax_id": ax_id,
1259
+ }
1260
+
1261
+ # Y Label
1262
+ ylabel_data = ax_data.get("ylabel", {})
1263
+ if ylabel_data and "bbox_px" in ylabel_data:
1264
+ bbox = ylabel_data["bbox_px"]
1265
+ if isinstance(bbox, list) and len(bbox) >= 4:
1266
+ bboxes[f"{ax_id}_ylabel"] = {
1267
+ "x0": safe_int(bbox[0] * scale_x, 0, img_width),
1268
+ "y0": safe_int(bbox[1] * scale_y, 0, img_height),
1269
+ "x1": safe_int(bbox[2] * scale_x, img_width, img_width),
1270
+ "y1": safe_int(bbox[3] * scale_y, img_height, img_height),
1271
+ "label": ylabel_data.get("text", "Y Label"),
1272
+ "element_type": "ylabel",
1273
+ "ax_id": ax_id,
1274
+ }
1275
+
1276
+ # X Axis spine
1277
+ xaxis_data = ax_data.get("xaxis", {})
1278
+ if xaxis_data:
1279
+ spine = xaxis_data.get("spine", {})
1280
+ if spine and "bbox_px" in spine:
1281
+ bbox = spine["bbox_px"]
1282
+ if isinstance(bbox, list) and len(bbox) >= 4:
1283
+ bboxes[f"{ax_id}_xaxis"] = {
1284
+ "x0": safe_int(bbox[0] * scale_x, 0, img_width),
1285
+ "y0": safe_int(bbox[1] * scale_y, 0, img_height),
1286
+ "x1": safe_int(bbox[2] * scale_x, img_width, img_width),
1287
+ "y1": safe_int(bbox[3] * scale_y, img_height, img_height),
1288
+ "label": "X Axis",
1289
+ "element_type": "xaxis",
1290
+ "ax_id": ax_id,
1291
+ }
1292
+
1293
+ # Y Axis spine
1294
+ yaxis_data = ax_data.get("yaxis", {})
1295
+ if yaxis_data:
1296
+ spine = yaxis_data.get("spine", {})
1297
+ if spine and "bbox_px" in spine:
1298
+ bbox = spine["bbox_px"]
1299
+ if isinstance(bbox, list) and len(bbox) >= 4:
1300
+ bboxes[f"{ax_id}_yaxis"] = {
1301
+ "x0": safe_int(bbox[0] * scale_x, 0, img_width),
1302
+ "y0": safe_int(bbox[1] * scale_y, 0, img_height),
1303
+ "x1": safe_int(bbox[2] * scale_x, img_width, img_width),
1304
+ "y1": safe_int(bbox[3] * scale_y, img_height, img_height),
1305
+ "label": "Y Axis",
1306
+ "element_type": "yaxis",
1307
+ "ax_id": ax_id,
1308
+ }
1309
+
1310
+ # Legend
1311
+ legend_data = ax_data.get("legend", {})
1312
+ if legend_data and "bbox_px" in legend_data:
1313
+ bbox = legend_data["bbox_px"]
1314
+ if isinstance(bbox, list) and len(bbox) >= 4:
1315
+ bboxes[f"{ax_id}_legend"] = {
1316
+ "x0": safe_int(bbox[0] * scale_x, 0, img_width),
1317
+ "y0": safe_int(bbox[1] * scale_y, 0, img_height),
1318
+ "x1": safe_int(bbox[2] * scale_x, img_width, img_width),
1319
+ "y1": safe_int(bbox[3] * scale_y, img_height, img_height),
1320
+ "label": "Legend",
1321
+ "element_type": "legend",
1322
+ "ax_id": ax_id,
1323
+ }
1324
+
1325
+ # If no bboxes found, return minimal set
1326
+ if not bboxes:
1327
+ bboxes["panel"] = {
1328
+ "x0": 0,
1329
+ "y0": 0,
1330
+ "x1": img_width,
1331
+ "y1": img_height,
1332
+ "label": "Panel",
1333
+ "is_panel": True,
1334
+ }
1335
+
1336
+ return bboxes
1337
+
1338
+
1339
+ # EOF