scitex 2.8.1__py3-none-any.whl → 2.10.2__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 (415) 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/dict/_pop_keys.py +1 -7
  105. scitex/dsp/__init__.py +15 -10
  106. scitex/dsp/add_noise.py +5 -2
  107. scitex/dsp/example.py +35 -22
  108. scitex/dsp/filt.py +8 -3
  109. scitex/dsp/reference.py +3 -2
  110. scitex/dsp/utils/__init__.py +2 -1
  111. scitex/dsp/utils/_differential_bandpass_filters.py +14 -4
  112. scitex/dt/__init__.py +39 -2
  113. scitex/errors.py +82 -521
  114. scitex/fig/__init__.py +4 -4
  115. scitex/fig/editor/edit/panel_loader.py +1 -1
  116. scitex/fig/io/_bundle.py +7 -7
  117. scitex/fts/README.md +262 -0
  118. scitex/fts/TODO.md +66 -0
  119. scitex/fts/__init__.py +90 -0
  120. scitex/fts/_bundle/README_IN_BUNDLE.md +102 -0
  121. scitex/fts/_bundle/_FTS.py +657 -0
  122. scitex/fts/_bundle/__init__.py +38 -0
  123. scitex/fts/_bundle/_children.py +216 -0
  124. scitex/fts/_bundle/_conversion/__init__.py +15 -0
  125. scitex/fts/_bundle/_conversion/_bundle2dict.py +44 -0
  126. scitex/fts/_bundle/_conversion/_dict2bundle.py +50 -0
  127. scitex/fts/_bundle/_dataclasses/_Axes.py +57 -0
  128. scitex/fts/_bundle/_dataclasses/_BBox.py +54 -0
  129. scitex/fts/_bundle/_dataclasses/_ColumnDef.py +72 -0
  130. scitex/fts/_bundle/_dataclasses/_DataFormat.py +40 -0
  131. scitex/fts/_bundle/_dataclasses/_DataInfo.py +135 -0
  132. scitex/fts/_bundle/_dataclasses/_DataSource.py +44 -0
  133. scitex/fts/_bundle/_dataclasses/_Node.py +319 -0
  134. scitex/fts/_bundle/_dataclasses/_NodeRefs.py +45 -0
  135. scitex/fts/_bundle/_dataclasses/_SizeMM.py +38 -0
  136. scitex/fts/_bundle/_dataclasses/__init__.py +35 -0
  137. scitex/fts/_bundle/_extractors/__init__.py +32 -0
  138. scitex/fts/_bundle/_extractors/_extract_bar.py +131 -0
  139. scitex/fts/_bundle/_extractors/_extract_line.py +71 -0
  140. scitex/fts/_bundle/_extractors/_extract_scatter.py +79 -0
  141. scitex/fts/_bundle/_loader.py +134 -0
  142. scitex/fts/_bundle/_mpl_helpers.py +389 -0
  143. scitex/fts/_bundle/_saver.py +269 -0
  144. scitex/fts/_bundle/_storage.py +200 -0
  145. scitex/fts/_bundle/_utils/__init__.py +55 -0
  146. scitex/fts/_bundle/_utils/_const.py +26 -0
  147. scitex/fts/_bundle/_utils/_errors.py +73 -0
  148. scitex/fts/_bundle/_utils/_generate.py +21 -0
  149. scitex/fts/_bundle/_utils/_types.py +76 -0
  150. scitex/fts/_bundle/_validation.py +434 -0
  151. scitex/fts/_bundle/_zipbundle.py +165 -0
  152. scitex/fts/_fig/__init__.py +22 -0
  153. scitex/fts/_fig/_backend/__init__.py +53 -0
  154. scitex/fts/_fig/_backend/_export.py +165 -0
  155. scitex/fts/_fig/_backend/_parser.py +188 -0
  156. scitex/fts/_fig/_backend/_render.py +538 -0
  157. scitex/fts/_fig/_composite.py +345 -0
  158. scitex/fts/_fig/_dataclasses/_ChannelEncoding.py +46 -0
  159. scitex/fts/_fig/_dataclasses/_Encoding.py +82 -0
  160. scitex/fts/_fig/_dataclasses/_Theme.py +441 -0
  161. scitex/fts/_fig/_dataclasses/_TraceEncoding.py +52 -0
  162. scitex/fts/_fig/_dataclasses/__init__.py +47 -0
  163. scitex/fts/_fig/_editor/__init__.py +14 -0
  164. scitex/fts/_fig/_editor/_cui/__init__.py +33 -0
  165. scitex/fts/_fig/_editor/_cui/_backend_detector.py +39 -0
  166. scitex/fts/_fig/_editor/_cui/_bundle_resolver.py +366 -0
  167. scitex/fts/_fig/_editor/_cui/_editor_launcher.py +175 -0
  168. scitex/fts/_fig/_editor/_cui/_manual_handler.py +52 -0
  169. scitex/fts/_fig/_editor/_cui/_panel_loader.py +246 -0
  170. scitex/fts/_fig/_editor/_cui/_path_resolver.py +66 -0
  171. scitex/fts/_fig/_editor/_defaults.py +300 -0
  172. scitex/fts/_fig/_editor/_gui/__init__.py +11 -0
  173. scitex/fts/_fig/_editor/_gui/_flask_editor/__init__.py +20 -0
  174. scitex/fts/_fig/_editor/_gui/_flask_editor/_bbox.py +1339 -0
  175. scitex/fts/_fig/_editor/_gui/_flask_editor/_core.py +1688 -0
  176. scitex/fts/_fig/_editor/_gui/_flask_editor/_plotter.py +664 -0
  177. scitex/fts/_fig/_editor/_gui/_flask_editor/_renderer.py +853 -0
  178. scitex/fts/_fig/_editor/_gui/_flask_editor/_utils.py +79 -0
  179. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/base/reset.css +41 -0
  180. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/base/typography.css +16 -0
  181. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/base/variables.css +85 -0
  182. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/buttons.css +217 -0
  183. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/context-menu.css +93 -0
  184. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/dropdown.css +57 -0
  185. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/forms.css +112 -0
  186. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/modal.css +59 -0
  187. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/components/sections.css +212 -0
  188. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/canvas.css +176 -0
  189. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/element-inspector.css +190 -0
  190. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/loading.css +59 -0
  191. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/overlay.css +45 -0
  192. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/panel-grid.css +95 -0
  193. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/selection.css +101 -0
  194. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/features/statistics.css +138 -0
  195. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/index.css +31 -0
  196. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/layout/container.css +7 -0
  197. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/layout/controls.css +56 -0
  198. scitex/fts/_fig/_editor/_gui/_flask_editor/static/css/layout/preview.css +78 -0
  199. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/alignment/axis.js +314 -0
  200. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/alignment/basic.js +107 -0
  201. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/alignment/distribute.js +54 -0
  202. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/canvas/canvas.js +172 -0
  203. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/canvas/dragging.js +258 -0
  204. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/canvas/resize.js +48 -0
  205. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/canvas/selection.js +71 -0
  206. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/core/api.js +288 -0
  207. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/core/state.js +143 -0
  208. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/core/utils.js +245 -0
  209. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/dev/element-inspector.js +992 -0
  210. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/editor/bbox.js +339 -0
  211. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/editor/element-drag.js +286 -0
  212. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/editor/overlay.js +371 -0
  213. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/editor/preview.js +293 -0
  214. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/main.js +426 -0
  215. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/shortcuts/context-menu.js +152 -0
  216. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/shortcuts/keyboard.js +265 -0
  217. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/ui/controls.js +184 -0
  218. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/ui/download.js +57 -0
  219. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/ui/help.js +100 -0
  220. scitex/fts/_fig/_editor/_gui/_flask_editor/static/js/ui/theme.js +34 -0
  221. scitex/fts/_fig/_editor/_gui/_flask_editor/templates/__init__.py +124 -0
  222. scitex/fts/_fig/_editor/_gui/_flask_editor/templates/_html.py +851 -0
  223. scitex/fts/_fig/_editor/_gui/_flask_editor/templates/_scripts.py +4932 -0
  224. scitex/fts/_fig/_editor/_gui/_flask_editor/templates/_styles.py +1657 -0
  225. scitex/fts/_fig/_editor/_gui/_flask_editor.py +36 -0
  226. scitex/fts/_fig/_models/_Annotations.py +115 -0
  227. scitex/fts/_fig/_models/_Axes.py +152 -0
  228. scitex/fts/_fig/_models/_Figure.py +138 -0
  229. scitex/fts/_fig/_models/_Guides.py +104 -0
  230. scitex/fts/_fig/_models/_Plot.py +123 -0
  231. scitex/fts/_fig/_models/_Styles.py +245 -0
  232. scitex/fts/_fig/_models/__init__.py +80 -0
  233. scitex/fts/_fig/_models/_plot_types/__init__.py +156 -0
  234. scitex/fts/_fig/_models/_plot_types/_bar.py +43 -0
  235. scitex/fts/_fig/_models/_plot_types/_box.py +38 -0
  236. scitex/fts/_fig/_models/_plot_types/_distribution.py +36 -0
  237. scitex/fts/_fig/_models/_plot_types/_errorbar.py +60 -0
  238. scitex/fts/_fig/_models/_plot_types/_histogram.py +30 -0
  239. scitex/fts/_fig/_models/_plot_types/_image.py +61 -0
  240. scitex/fts/_fig/_models/_plot_types/_line.py +57 -0
  241. scitex/fts/_fig/_models/_plot_types/_scatter.py +30 -0
  242. scitex/fts/_fig/_models/_plot_types/_seaborn.py +121 -0
  243. scitex/fts/_fig/_models/_plot_types/_violin.py +36 -0
  244. scitex/fts/_fig/_utils/__init__.py +129 -0
  245. scitex/fts/_fig/_utils/_auto_layout.py +127 -0
  246. scitex/fts/_fig/_utils/_calc_bounds.py +111 -0
  247. scitex/fts/_fig/_utils/_const_sizes.py +48 -0
  248. scitex/fts/_fig/_utils/_convert_coords.py +77 -0
  249. scitex/fts/_fig/_utils/_get_template.py +178 -0
  250. scitex/fts/_fig/_utils/_normalize.py +73 -0
  251. scitex/fts/_fig/_utils/_plot_layout.py +397 -0
  252. scitex/fts/_fig/_utils/_validate.py +197 -0
  253. scitex/fts/_kinds/__init__.py +45 -0
  254. scitex/fts/_kinds/_figure/__init__.py +19 -0
  255. scitex/fts/_kinds/_figure/_composite.py +345 -0
  256. scitex/fts/_kinds/_plot/__init__.py +25 -0
  257. scitex/fts/_kinds/_plot/_backend/__init__.py +53 -0
  258. scitex/fts/_kinds/_plot/_backend/_export.py +165 -0
  259. scitex/fts/_kinds/_plot/_backend/_parser.py +188 -0
  260. scitex/fts/_kinds/_plot/_backend/_render.py +538 -0
  261. scitex/fts/_kinds/_plot/_dataclasses/_ChannelEncoding.py +46 -0
  262. scitex/fts/_kinds/_plot/_dataclasses/_Encoding.py +82 -0
  263. scitex/fts/_kinds/_plot/_dataclasses/_Theme.py +441 -0
  264. scitex/fts/_kinds/_plot/_dataclasses/_TraceEncoding.py +52 -0
  265. scitex/fts/_kinds/_plot/_dataclasses/__init__.py +47 -0
  266. scitex/fts/_kinds/_plot/_models/_Annotations.py +115 -0
  267. scitex/fts/_kinds/_plot/_models/_Axes.py +152 -0
  268. scitex/fts/_kinds/_plot/_models/_Figure.py +138 -0
  269. scitex/fts/_kinds/_plot/_models/_Guides.py +104 -0
  270. scitex/fts/_kinds/_plot/_models/_Plot.py +123 -0
  271. scitex/fts/_kinds/_plot/_models/_Styles.py +245 -0
  272. scitex/fts/_kinds/_plot/_models/__init__.py +80 -0
  273. scitex/fts/_kinds/_plot/_models/_plot_types/__init__.py +156 -0
  274. scitex/fts/_kinds/_plot/_models/_plot_types/_bar.py +43 -0
  275. scitex/fts/_kinds/_plot/_models/_plot_types/_box.py +38 -0
  276. scitex/fts/_kinds/_plot/_models/_plot_types/_distribution.py +36 -0
  277. scitex/fts/_kinds/_plot/_models/_plot_types/_errorbar.py +60 -0
  278. scitex/fts/_kinds/_plot/_models/_plot_types/_histogram.py +30 -0
  279. scitex/fts/_kinds/_plot/_models/_plot_types/_image.py +61 -0
  280. scitex/fts/_kinds/_plot/_models/_plot_types/_line.py +57 -0
  281. scitex/fts/_kinds/_plot/_models/_plot_types/_scatter.py +30 -0
  282. scitex/fts/_kinds/_plot/_models/_plot_types/_seaborn.py +121 -0
  283. scitex/fts/_kinds/_plot/_models/_plot_types/_violin.py +36 -0
  284. scitex/fts/_kinds/_plot/_utils/__init__.py +129 -0
  285. scitex/fts/_kinds/_plot/_utils/_auto_layout.py +127 -0
  286. scitex/fts/_kinds/_plot/_utils/_calc_bounds.py +111 -0
  287. scitex/fts/_kinds/_plot/_utils/_const_sizes.py +48 -0
  288. scitex/fts/_kinds/_plot/_utils/_convert_coords.py +77 -0
  289. scitex/fts/_kinds/_plot/_utils/_get_template.py +178 -0
  290. scitex/fts/_kinds/_plot/_utils/_normalize.py +73 -0
  291. scitex/fts/_kinds/_plot/_utils/_plot_layout.py +397 -0
  292. scitex/fts/_kinds/_plot/_utils/_validate.py +197 -0
  293. scitex/fts/_kinds/_shape/__init__.py +141 -0
  294. scitex/fts/_kinds/_stats/__init__.py +56 -0
  295. scitex/fts/_kinds/_stats/_dataclasses/_Stats.py +423 -0
  296. scitex/fts/_kinds/_stats/_dataclasses/__init__.py +48 -0
  297. scitex/fts/_kinds/_table/__init__.py +72 -0
  298. scitex/fts/_kinds/_table/_latex/__init__.py +93 -0
  299. scitex/fts/_kinds/_table/_latex/_editor/__init__.py +11 -0
  300. scitex/fts/_kinds/_table/_latex/_editor/_app.py +725 -0
  301. scitex/fts/_kinds/_table/_latex/_export.py +279 -0
  302. scitex/fts/_kinds/_table/_latex/_figure_exporter.py +153 -0
  303. scitex/fts/_kinds/_table/_latex/_stats_formatter.py +274 -0
  304. scitex/fts/_kinds/_table/_latex/_table_exporter.py +362 -0
  305. scitex/fts/_kinds/_table/_latex/_utils.py +369 -0
  306. scitex/fts/_kinds/_table/_latex/_validator.py +445 -0
  307. scitex/fts/_kinds/_text/__init__.py +77 -0
  308. scitex/fts/_schemas/data_info.schema.json +75 -0
  309. scitex/fts/_schemas/encoding.schema.json +90 -0
  310. scitex/fts/_schemas/node.schema.json +145 -0
  311. scitex/fts/_schemas/render_manifest.schema.json +62 -0
  312. scitex/fts/_schemas/stats.schema.json +132 -0
  313. scitex/fts/_schemas/theme.schema.json +141 -0
  314. scitex/fts/_stats/__init__.py +48 -0
  315. scitex/fts/_stats/_dataclasses/_Stats.py +423 -0
  316. scitex/fts/_stats/_dataclasses/__init__.py +48 -0
  317. scitex/fts/_tables/__init__.py +65 -0
  318. scitex/fts/_tables/_latex/__init__.py +93 -0
  319. scitex/fts/_tables/_latex/_editor/__init__.py +11 -0
  320. scitex/fts/_tables/_latex/_editor/_app.py +725 -0
  321. scitex/fts/_tables/_latex/_export.py +279 -0
  322. scitex/fts/_tables/_latex/_figure_exporter.py +153 -0
  323. scitex/fts/_tables/_latex/_stats_formatter.py +274 -0
  324. scitex/fts/_tables/_latex/_table_exporter.py +362 -0
  325. scitex/fts/_tables/_latex/_utils.py +369 -0
  326. scitex/fts/_tables/_latex/_validator.py +445 -0
  327. scitex/gen/__init__.py +66 -25
  328. scitex/gen/misc.py +28 -0
  329. scitex/io/__init__.py +47 -32
  330. scitex/io/_load.py +87 -36
  331. scitex/io/_load_modules/__init__.py +10 -7
  332. scitex/io/_load_modules/_pandas.py +6 -1
  333. scitex/io/_save.py +299 -1556
  334. scitex/io/_save_modules/__init__.py +76 -19
  335. scitex/io/_save_modules/_figure_utils.py +90 -0
  336. scitex/io/_save_modules/_image_csv.py +497 -0
  337. scitex/io/_save_modules/_legends.py +91 -0
  338. scitex/io/_save_modules/_pltz_bundle.py +356 -0
  339. scitex/io/_save_modules/_pltz_stx.py +536 -0
  340. scitex/io/_save_modules/_stx_bundle.py +104 -0
  341. scitex/io/_save_modules/_symlink.py +96 -0
  342. scitex/io/_save_modules/_yaml.py +1 -1
  343. scitex/io/_save_modules/_zarr.py +64 -18
  344. scitex/io/bundle/README.md +212 -0
  345. scitex/io/bundle/__init__.py +110 -0
  346. scitex/io/{_bundle.py → bundle/_core.py} +168 -97
  347. scitex/io/bundle/_nested.py +713 -0
  348. scitex/io/bundle/_types.py +74 -0
  349. scitex/io/{_zip_bundle.py → bundle/_zip.py} +93 -45
  350. scitex/io/utils/h5_to_zarr.py +1 -1
  351. scitex/logging/__init__.py +108 -13
  352. scitex/logging/_errors.py +508 -0
  353. scitex/logging/_formatters.py +30 -6
  354. scitex/logging/_warnings.py +261 -0
  355. scitex/plt/__init__.py +4 -1
  356. scitex/plt/_figrecipe.py +236 -0
  357. scitex/plt/_subplots/_AxisWrapper.py +6 -0
  358. scitex/plt/_subplots/_AxisWrapperMixins/_UnitAwareMixin.py +112 -1
  359. scitex/plt/_subplots/_FigWrapper.py +15 -0
  360. scitex/plt/_subplots/_SubplotsWrapper.py +125 -489
  361. scitex/plt/_subplots/_export_as_csv.py +11 -0
  362. scitex/plt/_subplots/_export_as_csv_formatters/__init__.py +2 -0
  363. scitex/plt/_subplots/_export_as_csv_formatters/_format_pcolormesh.py +66 -0
  364. scitex/plt/_subplots/_export_as_csv_formatters/_format_stackplot.py +62 -0
  365. scitex/plt/_subplots/_export_as_csv_formatters/test_formatters.py +208 -0
  366. scitex/plt/_subplots/_fonts.py +71 -0
  367. scitex/plt/_subplots/_mm_layout.py +282 -0
  368. scitex/plt/gallery/__init__.py +99 -2
  369. scitex/plt/styles/_plot_postprocess.py +3 -1
  370. scitex/plt/utils/_configure_mpl.py +16 -19
  371. scitex/repro/_RandomStateManager.py +13 -8
  372. scitex/resource/__init__.py +19 -1
  373. scitex/resource/_utils/_get_env_info.py +13 -25
  374. scitex/schema/__init__.py +149 -160
  375. scitex/schema/_encoding.py +273 -0
  376. scitex/schema/_figure_elements.py +406 -0
  377. scitex/schema/_theme.py +360 -0
  378. scitex/schema/_validation.py +0 -98
  379. scitex/scholar/__init__.py +56 -14
  380. scitex/scholar/auth/ScholarAuthManager.py +1 -1
  381. scitex/scholar/auth/__init__.py +11 -2
  382. scitex/scholar/auth/providers/BaseAuthenticator.py +1 -1
  383. scitex/scholar/auth/providers/EZProxyAuthenticator.py +1 -1
  384. scitex/scholar/auth/providers/OpenAthensAuthenticator.py +1 -1
  385. scitex/scholar/auth/providers/ShibbolethAuthenticator.py +1 -1
  386. scitex/scholar/config/ScholarConfig.py +1 -1
  387. scitex/scholar/core/Scholar.py +1 -1
  388. scitex/session/_decorator.py +18 -16
  389. scitex/session/_lifecycle.py +9 -11
  390. scitex/session/template.py +9 -8
  391. scitex/sh/test_sh.py +72 -0
  392. scitex/sh/test_sh_simple.py +61 -0
  393. scitex/stats/__init__.py +221 -97
  394. scitex/stats/_schema.py +21 -22
  395. scitex/stats/descriptive/_circular.py +212 -351
  396. scitex/stats/descriptive/_describe.py +81 -132
  397. scitex/stats/descriptive/_nan.py +205 -433
  398. scitex/stats/descriptive/_real.py +127 -141
  399. scitex/str/_format_plot_text.py +5 -5
  400. scitex/str/_latex.py +26 -84
  401. scitex/str/_latex_fallback.py +53 -47
  402. scitex/web/_search_pubmed.py +5 -4
  403. scitex/writer/tests/test_diff_between.py +451 -0
  404. scitex/writer/tests/test_document_section.py +311 -0
  405. scitex/writer/tests/test_document_workflow.py +393 -0
  406. scitex/writer/tests/test_writer.py +361 -0
  407. scitex/writer/tests/test_writer_integration.py +303 -0
  408. {scitex-2.8.1.dist-info → scitex-2.10.2.dist-info}/METADATA +368 -183
  409. {scitex-2.8.1.dist-info → scitex-2.10.2.dist-info}/RECORD +412 -97
  410. scitex/scholar/docs/to_claude/guidelines/examples/mgmt/ARCHITECTURE_EXAMPLE.md +0 -905
  411. scitex/scholar/docs/to_claude/guidelines/examples/mgmt/BULLETIN_BOARD_EXAMPLE.md +0 -99
  412. scitex/scholar/docs/to_claude/guidelines/examples/mgmt/PROJECT_DESCRIPTION_EXAMPLE.md +0 -96
  413. {scitex-2.8.1.dist-info → scitex-2.10.2.dist-info}/WHEEL +0 -0
  414. {scitex-2.8.1.dist-info → scitex-2.10.2.dist-info}/entry_points.txt +0 -0
  415. {scitex-2.8.1.dist-info → scitex-2.10.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1688 @@
1
+ #!/usr/bin/env python3
2
+ # File: ./src/scitex/vis/editor/flask_editor/core.py
3
+ """Core WebEditor class for Flask-based figure editing."""
4
+
5
+ import base64
6
+ import copy
7
+ import json
8
+ import threading
9
+ import webbrowser
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Optional
12
+
13
+ from ._utils import check_port_available, kill_process_on_port
14
+ from .templates import build_html_template
15
+
16
+
17
+ class WebEditor:
18
+ """
19
+ Browser-based figure editor using Flask.
20
+
21
+ Features:
22
+ - Displays existing PNG from pltz bundle (no re-rendering)
23
+ - Hitmap-based element selection for precise clicking
24
+ - Property editors with sliders and color pickers
25
+ - Save to .manual.json
26
+ - SciTeX style defaults pre-filled
27
+ - Auto-finds available port if default is in use
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ json_path: Path,
33
+ metadata: Dict[str, Any],
34
+ csv_data: Optional[Any] = None,
35
+ png_path: Optional[Path] = None,
36
+ hitmap_path: Optional[Path] = None,
37
+ manual_overrides: Optional[Dict[str, Any]] = None,
38
+ port: int = 5050,
39
+ panel_info: Optional[Dict[str, Any]] = None,
40
+ ):
41
+ self.json_path = Path(json_path)
42
+ self.metadata = metadata
43
+ self.csv_data = csv_data
44
+ self.png_path = Path(png_path) if png_path else None
45
+ self.hitmap_path = Path(hitmap_path) if hitmap_path else None
46
+ self.manual_overrides = manual_overrides or {}
47
+ self._requested_port = port
48
+ self.port = port
49
+ self.panel_info = panel_info # For multi-panel figz bundles
50
+
51
+ # Extract hit_regions from metadata for color-based element detection
52
+ self.hit_regions = metadata.get("hit_regions", {})
53
+ self.color_map = self.hit_regions.get("color_map", {})
54
+
55
+ # Get SciTeX defaults and merge with metadata
56
+ from .._defaults import extract_defaults_from_metadata, get_scitex_defaults
57
+
58
+ self.scitex_defaults = get_scitex_defaults()
59
+ self.metadata_defaults = extract_defaults_from_metadata(metadata)
60
+
61
+ # Start with defaults, then overlay manual overrides
62
+ self.current_overrides = copy.deepcopy(self.scitex_defaults)
63
+ self.current_overrides.update(self.metadata_defaults)
64
+ self.current_overrides.update(self.manual_overrides)
65
+
66
+ # Track initial state to detect modifications
67
+ self._initial_overrides = copy.deepcopy(self.current_overrides)
68
+ self._user_modified = False
69
+
70
+ def run(self):
71
+ """Launch the web editor."""
72
+ try:
73
+ from flask import Flask, jsonify, render_template_string, request
74
+ except ImportError:
75
+ raise ImportError(
76
+ "Flask is required for web editor. Install: pip install flask"
77
+ )
78
+
79
+ # Handle port conflicts - always use port 5050
80
+ import time
81
+
82
+ max_retries = 3
83
+ for attempt in range(max_retries):
84
+ if check_port_available(self._requested_port):
85
+ self.port = self._requested_port
86
+ break
87
+ print(
88
+ f"Port {self._requested_port} in use. Freeing... (attempt {attempt + 1}/{max_retries})"
89
+ )
90
+ kill_process_on_port(self._requested_port)
91
+ time.sleep(1.0) # Wait for port release
92
+ else:
93
+ # After retries, use requested port anyway (Flask will error if unavailable)
94
+ print(f"Warning: Port {self._requested_port} may still be in use")
95
+ self.port = self._requested_port
96
+
97
+ # Configure Flask with static folder path
98
+ import os
99
+
100
+ static_folder = os.path.join(os.path.dirname(__file__), "static")
101
+ app = Flask(__name__, static_folder=static_folder, static_url_path="/static")
102
+ editor = self
103
+
104
+ def _export_composed_figure(editor, formats=["png", "svg"], dpi=150):
105
+ """Helper to compose and export figure to bundle."""
106
+ import matplotlib
107
+ import numpy as np
108
+ from PIL import Image
109
+
110
+ from scitex.fts._bundle._zipbundle import ZipBundle
111
+
112
+ matplotlib.use("Agg")
113
+ import io
114
+ import json as json_module
115
+ import zipfile
116
+
117
+ import matplotlib.pyplot as plt
118
+
119
+ if not editor.panel_info:
120
+ return {"success": False, "error": "No panel info"}
121
+
122
+ bundle_path = editor.panel_info.get("bundle_path")
123
+ figz_dir = editor.panel_info.get("figz_dir")
124
+
125
+ if not bundle_path and not figz_dir:
126
+ return {"success": False, "error": "No bundle path"}
127
+
128
+ figure_name = (
129
+ Path(bundle_path).stem
130
+ if bundle_path
131
+ else (
132
+ Path(figz_dir).stem.replace(".figz.d", "") if figz_dir else "figure"
133
+ )
134
+ )
135
+
136
+ # Read spec.json for layout and layout.json for position overrides
137
+ spec = {}
138
+ layout_overrides = {}
139
+ if bundle_path:
140
+ try:
141
+ with ZipBundle(bundle_path, mode="r") as bundle:
142
+ spec = bundle.read_json("spec.json")
143
+ try:
144
+ layout_overrides = bundle.read_json("layout.json")
145
+ except:
146
+ pass
147
+ except:
148
+ pass
149
+ elif figz_dir:
150
+ spec_path = Path(figz_dir) / "spec.json"
151
+ if spec_path.exists():
152
+ with open(spec_path) as f:
153
+ spec = json_module.load(f)
154
+ layout_path = Path(figz_dir) / "layout.json"
155
+ if layout_path.exists():
156
+ with open(layout_path) as f:
157
+ layout_overrides = json_module.load(f)
158
+
159
+ # Also check in-memory layout overrides
160
+ if editor.panel_info and editor.panel_info.get("layout"):
161
+ layout_overrides = editor.panel_info.get("layout", {})
162
+
163
+ # Get figure dimensions
164
+ fig_width_mm = 180
165
+ fig_height_mm = 120
166
+ if "figure" in spec:
167
+ fig_info = spec.get("figure", {})
168
+ styles = fig_info.get("styles", {})
169
+ size = styles.get("size", {})
170
+ fig_width_mm = size.get("width_mm", 180)
171
+ fig_height_mm = size.get("height_mm", 120)
172
+
173
+ fig_width_in = fig_width_mm / 25.4
174
+ fig_height_in = fig_height_mm / 25.4
175
+
176
+ fig = plt.figure(
177
+ figsize=(fig_width_in, fig_height_in), dpi=dpi, facecolor="white"
178
+ )
179
+
180
+ # Compose panels
181
+ panels_spec = spec.get("panels", [])
182
+ panel_paths = editor.panel_info.get("panel_paths", [])
183
+ panel_is_zip = editor.panel_info.get("panel_is_zip", [])
184
+
185
+ for panel_spec in panels_spec:
186
+ panel_id = panel_spec.get("id", "")
187
+ pos = panel_spec.get("position", {})
188
+ size = panel_spec.get("size", {})
189
+
190
+ # Skip overview/auxiliary panels (only compose main panels A-Z)
191
+ panel_id_lower = panel_id.lower()
192
+ if any(
193
+ skip in panel_id_lower
194
+ for skip in ["overview", "thumb", "preview", "aux"]
195
+ ):
196
+ continue
197
+
198
+ # Find panel path first (needed to check layout_overrides)
199
+ panel_path = None
200
+ is_zip = False
201
+ panel_name = None
202
+ for idx, pp in enumerate(panel_paths):
203
+ pp_name = Path(pp).stem.replace(".pltz", "")
204
+ if (
205
+ pp_name == panel_id
206
+ or pp_name.startswith(f"panel_{panel_id}_")
207
+ or pp_name == f"panel_{panel_id}"
208
+ or f"_{panel_id}_" in pp_name
209
+ ):
210
+ panel_path = pp
211
+ panel_name = Path(pp).name # e.g., "panel_A_twinx.pltz"
212
+ is_zip = panel_is_zip[idx] if idx < len(panel_is_zip) else False
213
+ break
214
+
215
+ if not panel_path:
216
+ continue
217
+
218
+ # Check for layout overrides (from layout.json or in-memory)
219
+ override = layout_overrides.get(panel_name, {})
220
+ override_pos = override.get("position", {})
221
+ override_size = override.get("size", {})
222
+
223
+ # Use override positions if available, otherwise use spec
224
+ x_mm = override_pos.get("x_mm", pos.get("x_mm", 0))
225
+ y_mm = override_pos.get("y_mm", pos.get("y_mm", 0))
226
+ w_mm = override_size.get("width_mm", size.get("width_mm", 60))
227
+ h_mm = override_size.get("height_mm", size.get("height_mm", 40))
228
+
229
+ x_frac = x_mm / fig_width_mm
230
+ y_frac = 1 - (y_mm + h_mm) / fig_height_mm
231
+ w_frac = w_mm / fig_width_mm
232
+ h_frac = h_mm / fig_height_mm
233
+
234
+ # Load panel preview
235
+ try:
236
+ # Exclusion patterns for preview selection
237
+ exclude_patterns = ["hitmap", "overview", "thumb", "preview"]
238
+
239
+ if is_zip:
240
+ with ZipBundle(panel_path, mode="r") as pltz_bundle:
241
+ with zipfile.ZipFile(panel_path, "r") as zf:
242
+ png_files = [
243
+ n
244
+ for n in zf.namelist()
245
+ if n.endswith(".png")
246
+ and "exports/" in n
247
+ and not any(
248
+ p in n.lower() for p in exclude_patterns
249
+ )
250
+ ]
251
+ if png_files:
252
+ preview_path = png_files[0]
253
+ if ".pltz.d/" in preview_path:
254
+ preview_path = preview_path.split(".pltz.d/")[
255
+ -1
256
+ ]
257
+ img_data = pltz_bundle.read_bytes(preview_path)
258
+ img = Image.open(io.BytesIO(img_data))
259
+ ax = fig.add_axes([x_frac, y_frac, w_frac, h_frac])
260
+ ax.imshow(np.array(img))
261
+ ax.axis("off")
262
+ else:
263
+ pltz_dir = Path(panel_path)
264
+ exports_dir = pltz_dir / "exports"
265
+ if exports_dir.exists():
266
+ for png_file in exports_dir.glob("*.png"):
267
+ name_lower = png_file.name.lower()
268
+ if not any(p in name_lower for p in exclude_patterns):
269
+ img = Image.open(png_file)
270
+ ax = fig.add_axes([x_frac, y_frac, w_frac, h_frac])
271
+ ax.imshow(np.array(img))
272
+ ax.axis("off")
273
+ break
274
+ except Exception as e:
275
+ print(f"Could not load panel {panel_id}: {e}")
276
+
277
+ # Draw panel letter
278
+ if (
279
+ panel_id and len(panel_id) <= 2
280
+ ): # Only for short IDs like A, B, C...
281
+ # Position letter at top-left corner of panel
282
+ letter_x = x_frac + 0.01
283
+ letter_y = y_frac + h_frac - 0.02
284
+ fig.text(
285
+ letter_x,
286
+ letter_y,
287
+ panel_id,
288
+ fontsize=14,
289
+ fontweight="bold",
290
+ color="black",
291
+ ha="left",
292
+ va="top",
293
+ transform=fig.transFigure,
294
+ bbox=dict(
295
+ boxstyle="square,pad=0.1",
296
+ facecolor="white",
297
+ edgecolor="none",
298
+ alpha=0.8,
299
+ ),
300
+ )
301
+
302
+ exported = {}
303
+
304
+ # Save to bundle
305
+ if bundle_path:
306
+ with ZipBundle(bundle_path, mode="a") as bundle:
307
+ for fmt in formats:
308
+ buf = io.BytesIO()
309
+ fig.savefig(
310
+ buf,
311
+ format=fmt,
312
+ dpi=dpi,
313
+ bbox_inches="tight",
314
+ facecolor="white",
315
+ pad_inches=0.02,
316
+ )
317
+ buf.seek(0)
318
+ export_path = f"exports/{figure_name}.{fmt}"
319
+ bundle.write_bytes(export_path, buf.read())
320
+ exported[fmt] = export_path
321
+
322
+ plt.close(fig)
323
+ return {"success": True, "exported": exported}
324
+
325
+ @app.route("/")
326
+ def index():
327
+ # Rebuild template each time for hot reload support
328
+ html_template = build_html_template()
329
+
330
+ # Extract figz and panel paths for display
331
+ json_path_str = str(editor.json_path.resolve())
332
+ figz_path = ""
333
+ panel_path = ""
334
+
335
+ # Check if this is inside a figz bundle
336
+ if ".figz.d/" in json_path_str:
337
+ parts = json_path_str.split(".figz.d/")
338
+ figz_path = parts[0] + ".figz.d"
339
+ panel_path = parts[1] if len(parts) > 1 else ""
340
+ elif ".pltz.d/" in json_path_str:
341
+ parts = json_path_str.split(".pltz.d/")
342
+ figz_path = parts[0] + ".pltz.d"
343
+ panel_path = parts[1] if len(parts) > 1 else ""
344
+ else:
345
+ figz_path = json_path_str
346
+
347
+ return render_template_string(
348
+ html_template,
349
+ filename=figz_path,
350
+ panel_path=panel_path,
351
+ overrides=json.dumps(editor.current_overrides),
352
+ )
353
+
354
+ @app.route("/preview")
355
+ def preview():
356
+ """Render figure preview with current overrides (same logic as /update)."""
357
+ from ._renderer import render_preview_with_bboxes
358
+
359
+ # Always use renderer for consistency between initial and updated views
360
+ dark_mode = request.args.get("dark_mode", "false").lower() == "true"
361
+ img_data, bboxes, img_size = render_preview_with_bboxes(
362
+ editor.csv_data,
363
+ editor.current_overrides,
364
+ metadata=editor.metadata,
365
+ dark_mode=dark_mode,
366
+ )
367
+ return jsonify(
368
+ {
369
+ "image": img_data,
370
+ "bboxes": bboxes,
371
+ "img_size": img_size,
372
+ "has_hitmap": editor.hitmap_path is not None
373
+ and editor.hitmap_path.exists(),
374
+ "format": "png",
375
+ "panel_info": editor.panel_info,
376
+ }
377
+ )
378
+
379
+ @app.route("/panels")
380
+ def panels():
381
+ """Return all panel images with bboxes for interactive grid view (figz bundles only).
382
+
383
+ Uses smart load_panel_data helper for transparent zip/directory handling.
384
+ Returns layout info from figz spec.json for unified canvas positioning.
385
+ """
386
+ import json as json_module
387
+
388
+ from ..edit import load_panel_data
389
+ from ._bbox import (
390
+ extract_bboxes_from_geometry_px,
391
+ extract_bboxes_from_metadata,
392
+ )
393
+
394
+ if not editor.panel_info:
395
+ return jsonify({"error": "Not a multi-panel figz bundle"}), 400
396
+
397
+ panel_names = editor.panel_info["panels"]
398
+ panel_paths = editor.panel_info.get("panel_paths", [])
399
+ panel_is_zip = editor.panel_info.get(
400
+ "panel_is_zip", [False] * len(panel_names)
401
+ )
402
+ figz_dir = Path(editor.panel_info["figz_dir"])
403
+
404
+ if not panel_paths:
405
+ panel_paths = [str(figz_dir / name) for name in panel_names]
406
+
407
+ # Load figz spec.json to get panel layout
408
+ figz_layout = {}
409
+ spec_path = figz_dir / "spec.json"
410
+ if spec_path.exists():
411
+ with open(spec_path) as f:
412
+ figz_spec = json_module.load(f)
413
+ for panel_spec in figz_spec.get("panels", []):
414
+ panel_id = panel_spec.get("id", "")
415
+ figz_layout[panel_id] = {
416
+ "position": panel_spec.get("position", {}),
417
+ "size": panel_spec.get("size", {}),
418
+ }
419
+
420
+ panel_images = []
421
+
422
+ for idx, panel_name in enumerate(panel_names):
423
+ panel_path = panel_paths[idx]
424
+ is_zip = panel_is_zip[idx] if idx < len(panel_is_zip) else None
425
+ display_name = panel_name.replace(".pltz.d", "").replace(".pltz", "")
426
+
427
+ # Use smart helper to load panel data
428
+ loaded = load_panel_data(panel_path, is_zip=is_zip)
429
+
430
+ panel_data = {
431
+ "name": display_name,
432
+ "image": None,
433
+ "bboxes": None,
434
+ "img_size": None,
435
+ }
436
+
437
+ # Add layout info from figz spec
438
+ if display_name in figz_layout:
439
+ panel_data["layout"] = figz_layout[display_name]
440
+
441
+ if loaded:
442
+ # Get image data
443
+ if loaded.get("is_zip"):
444
+ png_bytes = loaded.get("png_bytes")
445
+ if png_bytes:
446
+ panel_data["image"] = base64.b64encode(png_bytes).decode(
447
+ "utf-8"
448
+ )
449
+ else:
450
+ png_path = loaded.get("png_path")
451
+ if png_path and png_path.exists():
452
+ with open(png_path, "rb") as f:
453
+ panel_data["image"] = base64.b64encode(f.read()).decode(
454
+ "utf-8"
455
+ )
456
+
457
+ # Get image size
458
+ img_size = loaded.get("img_size")
459
+ if img_size:
460
+ panel_data["img_size"] = img_size
461
+ panel_data["width"] = img_size["width"]
462
+ panel_data["height"] = img_size["height"]
463
+ elif loaded.get("png_path"):
464
+ from PIL import Image
465
+
466
+ img = Image.open(loaded["png_path"])
467
+ panel_data["img_size"] = {
468
+ "width": img.size[0],
469
+ "height": img.size[1],
470
+ }
471
+ panel_data["width"], panel_data["height"] = img.size
472
+ img.close()
473
+
474
+ # Extract bboxes - prefer geometry_px.json
475
+ if panel_data.get("img_size"):
476
+ geometry_data = loaded.get("geometry_data")
477
+ metadata = loaded.get("metadata", {})
478
+
479
+ if geometry_data:
480
+ panel_data["bboxes"] = extract_bboxes_from_geometry_px(
481
+ geometry_data,
482
+ panel_data["img_size"]["width"],
483
+ panel_data["img_size"]["height"],
484
+ )
485
+ elif metadata:
486
+ panel_data["bboxes"] = extract_bboxes_from_metadata(
487
+ metadata,
488
+ panel_data["img_size"]["width"],
489
+ panel_data["img_size"]["height"],
490
+ )
491
+
492
+ panel_images.append(panel_data)
493
+
494
+ return jsonify(
495
+ {
496
+ "panels": panel_images,
497
+ "count": len(panel_images),
498
+ "layout": figz_layout,
499
+ }
500
+ )
501
+
502
+ @app.route("/switch_panel/<int:panel_index>")
503
+ def switch_panel(panel_index):
504
+ """Switch to a different panel in the figz bundle.
505
+
506
+ Uses smart load_panel_data helper for transparent zip/directory handling.
507
+ """
508
+ from ..edit import load_panel_data
509
+ from ._bbox import (
510
+ extract_bboxes_from_geometry_px,
511
+ extract_bboxes_from_metadata,
512
+ )
513
+
514
+ if not editor.panel_info:
515
+ return jsonify({"error": "Not a multi-panel figz bundle"}), 400
516
+
517
+ panels = editor.panel_info["panels"]
518
+ panel_paths = editor.panel_info.get("panel_paths", [])
519
+ panel_is_zip = editor.panel_info.get("panel_is_zip", [False] * len(panels))
520
+
521
+ if panel_index < 0 or panel_index >= len(panels):
522
+ return jsonify({"error": f"Invalid panel index: {panel_index}"}), 400
523
+
524
+ panel_name = panels[panel_index]
525
+ panel_path = (
526
+ panel_paths[panel_index]
527
+ if panel_paths
528
+ else str(Path(editor.panel_info["figz_dir"]) / panel_name)
529
+ )
530
+ is_zip = (
531
+ panel_is_zip[panel_index] if panel_index < len(panel_is_zip) else None
532
+ )
533
+
534
+ try:
535
+ # Use smart helper to load panel data
536
+ loaded = load_panel_data(panel_path, is_zip=is_zip)
537
+
538
+ if not loaded:
539
+ return (
540
+ jsonify({"error": f"Could not load panel: {panel_name}"}),
541
+ 400,
542
+ )
543
+
544
+ # Get image data
545
+ img_data = None
546
+ if loaded.get("is_zip"):
547
+ png_bytes = loaded.get("png_bytes")
548
+ if png_bytes:
549
+ img_data = base64.b64encode(png_bytes).decode("utf-8")
550
+ else:
551
+ png_path = loaded.get("png_path")
552
+ if png_path and png_path.exists():
553
+ with open(png_path, "rb") as f:
554
+ img_data = base64.b64encode(f.read()).decode("utf-8")
555
+
556
+ if not img_data:
557
+ return (
558
+ jsonify({"error": f"No PNG found for panel: {panel_name}"}),
559
+ 400,
560
+ )
561
+
562
+ # Get image size
563
+ img_size = loaded.get("img_size", {"width": 0, "height": 0})
564
+ if not img_size and loaded.get("png_path"):
565
+ from PIL import Image
566
+
567
+ img = Image.open(loaded["png_path"])
568
+ img_size = {"width": img.size[0], "height": img.size[1]}
569
+ img.close()
570
+
571
+ # Extract bboxes - prefer geometry_px.json
572
+ bboxes = {}
573
+ geometry_data = loaded.get("geometry_data")
574
+ metadata = loaded.get("metadata", {})
575
+
576
+ if geometry_data and img_size:
577
+ bboxes = extract_bboxes_from_geometry_px(
578
+ geometry_data, img_size["width"], img_size["height"]
579
+ )
580
+ elif metadata and img_size:
581
+ bboxes = extract_bboxes_from_metadata(
582
+ metadata, img_size["width"], img_size["height"]
583
+ )
584
+
585
+ # Update editor state
586
+ editor.metadata = metadata
587
+ editor.panel_info["current_index"] = panel_index
588
+
589
+ # Re-extract defaults from new metadata
590
+ from .._defaults import (
591
+ extract_defaults_from_metadata,
592
+ get_scitex_defaults,
593
+ )
594
+
595
+ editor.scitex_defaults = get_scitex_defaults()
596
+ editor.metadata_defaults = extract_defaults_from_metadata(metadata)
597
+ editor.current_overrides = copy.deepcopy(editor.scitex_defaults)
598
+ editor.current_overrides.update(editor.metadata_defaults)
599
+ editor.current_overrides.update(editor.manual_overrides)
600
+
601
+ return jsonify(
602
+ {
603
+ "success": True,
604
+ "panel_name": panel_name,
605
+ "panel_index": panel_index,
606
+ "image": img_data,
607
+ "bboxes": bboxes,
608
+ "img_size": img_size,
609
+ "overrides": editor.current_overrides,
610
+ }
611
+ )
612
+ except Exception as e:
613
+ import traceback
614
+
615
+ return (
616
+ jsonify(
617
+ {
618
+ "error": f"Failed to switch panel: {str(e)}",
619
+ "traceback": traceback.format_exc(),
620
+ }
621
+ ),
622
+ 500,
623
+ )
624
+
625
+ @app.route("/hitmap")
626
+ def hitmap():
627
+ """Return hitmap PNG for element detection."""
628
+ if editor.hitmap_path and editor.hitmap_path.exists():
629
+ with open(editor.hitmap_path, "rb") as f:
630
+ img_data = base64.b64encode(f.read()).decode("utf-8")
631
+ return jsonify(
632
+ {
633
+ "image": img_data,
634
+ "color_map": editor.color_map,
635
+ }
636
+ )
637
+ return jsonify({"error": "No hitmap available"}), 404
638
+
639
+ @app.route("/color_map")
640
+ def color_map():
641
+ """Return color map for hitmap element identification."""
642
+ return jsonify(
643
+ {
644
+ "color_map": editor.color_map,
645
+ "hit_regions": editor.hit_regions,
646
+ }
647
+ )
648
+
649
+ @app.route("/update", methods=["POST"])
650
+ def update():
651
+ """Update overrides and re-render with updated properties."""
652
+ from ._renderer import render_preview_with_bboxes
653
+
654
+ data = request.json
655
+ editor.current_overrides.update(data.get("overrides", {}))
656
+ editor._user_modified = True
657
+
658
+ # Check if dark mode is requested from POST data
659
+ dark_mode = data.get("dark_mode", False)
660
+
661
+ # Re-render the figure with updated overrides
662
+ img_data, bboxes, img_size = render_preview_with_bboxes(
663
+ editor.csv_data,
664
+ editor.current_overrides,
665
+ metadata=editor.metadata,
666
+ dark_mode=dark_mode,
667
+ )
668
+ return jsonify(
669
+ {
670
+ "image": img_data,
671
+ "bboxes": bboxes,
672
+ "img_size": img_size,
673
+ "status": "updated",
674
+ }
675
+ )
676
+
677
+ @app.route("/save", methods=["POST"])
678
+ def save():
679
+ """Save to .manual.json."""
680
+ from ..edit import save_manual_overrides
681
+
682
+ try:
683
+ manual_path = save_manual_overrides(
684
+ editor.json_path, editor.current_overrides
685
+ )
686
+ return jsonify({"status": "saved", "path": str(manual_path)})
687
+ except Exception as e:
688
+ return jsonify({"status": "error", "message": str(e)}), 500
689
+
690
+ @app.route("/save_layout", methods=["POST"])
691
+ def save_layout():
692
+ """Save panel layout positions to figz bundle."""
693
+ try:
694
+ data = request.get_json()
695
+ layout = data.get("layout", {})
696
+
697
+ if not layout:
698
+ return jsonify(
699
+ {"success": False, "error": "No layout data provided"}
700
+ )
701
+
702
+ # Check if we have panel_info (figz bundle)
703
+ if not editor.panel_info:
704
+ return jsonify(
705
+ {
706
+ "success": False,
707
+ "error": "No panel info available (not a figz bundle)",
708
+ }
709
+ )
710
+
711
+ bundle_path = editor.panel_info.get("bundle_path")
712
+ if not bundle_path:
713
+ return jsonify(
714
+ {"success": False, "error": "Bundle path not available"}
715
+ )
716
+
717
+ # Update layout in the figz bundle
718
+ from scitex.fts._bundle._zipbundle import ZipBundle
719
+
720
+ bundle = ZipBundle(bundle_path)
721
+
722
+ # Read existing layout or create new one
723
+ try:
724
+ existing_layout = bundle.read_json("layout.json")
725
+ except:
726
+ existing_layout = {}
727
+
728
+ # Update layout with new positions
729
+ for panel_name, pos in layout.items():
730
+ if panel_name not in existing_layout:
731
+ existing_layout[panel_name] = {}
732
+ if "position" not in existing_layout[panel_name]:
733
+ existing_layout[panel_name]["position"] = {}
734
+ if "size" not in existing_layout[panel_name]:
735
+ existing_layout[panel_name]["size"] = {}
736
+
737
+ # Update position
738
+ existing_layout[panel_name]["position"]["x_mm"] = pos.get("x_mm", 0)
739
+ existing_layout[panel_name]["position"]["y_mm"] = pos.get("y_mm", 0)
740
+
741
+ # Update size if provided
742
+ if "width_mm" in pos:
743
+ existing_layout[panel_name]["size"]["width_mm"] = pos[
744
+ "width_mm"
745
+ ]
746
+ if "height_mm" in pos:
747
+ existing_layout[panel_name]["size"]["height_mm"] = pos[
748
+ "height_mm"
749
+ ]
750
+
751
+ # Save updated layout
752
+ bundle.write_json("layout.json", existing_layout)
753
+
754
+ # Update in-memory panel_info
755
+ editor.panel_info["layout"] = existing_layout
756
+
757
+ # Auto-export composed figure to bundle
758
+ export_result = _export_composed_figure(editor, formats=["png", "svg"])
759
+
760
+ return jsonify(
761
+ {
762
+ "success": True,
763
+ "layout": existing_layout,
764
+ "exported": export_result.get("exported", {}),
765
+ }
766
+ )
767
+
768
+ except Exception as e:
769
+ import traceback
770
+
771
+ return jsonify(
772
+ {
773
+ "success": False,
774
+ "error": str(e),
775
+ "traceback": traceback.format_exc(),
776
+ }
777
+ )
778
+
779
+ @app.route("/save_element_position", methods=["POST"])
780
+ def save_element_position():
781
+ """Save element position (legend/panel_letter) to figz bundle.
782
+
783
+ ONLY legends and panel letters can be repositioned to maintain
784
+ scientific rigor. Data elements are never moved.
785
+ """
786
+ try:
787
+ data = request.get_json()
788
+ element = data.get("element", "")
789
+ panel = data.get("panel", "")
790
+ element_type = data.get("element_type", "")
791
+ position = data.get("position", {})
792
+ snap_name = data.get("snap_name")
793
+
794
+ # Validate element type (whitelist for scientific rigor)
795
+ ALLOWED_TYPES = ["legend", "panel_letter"]
796
+ if element_type not in ALLOWED_TYPES:
797
+ return jsonify(
798
+ {
799
+ "success": False,
800
+ "error": f"Element type '{element_type}' cannot be repositioned (scientific rigor)",
801
+ }
802
+ )
803
+
804
+ if not editor.panel_info:
805
+ return jsonify(
806
+ {"success": False, "error": "No panel info available"}
807
+ )
808
+
809
+ bundle_path = editor.panel_info.get("bundle_path")
810
+ if not bundle_path:
811
+ return jsonify(
812
+ {"success": False, "error": "Bundle path not available"}
813
+ )
814
+
815
+ from scitex.fts._bundle._zipbundle import ZipBundle
816
+
817
+ bundle = ZipBundle(bundle_path)
818
+
819
+ # Read or create style.json for element positions
820
+ try:
821
+ style = bundle.read_json("style.json")
822
+ except:
823
+ style = {}
824
+
825
+ # Initialize structure
826
+ if "elements" not in style:
827
+ style["elements"] = {}
828
+ if panel not in style["elements"]:
829
+ style["elements"][panel] = {}
830
+
831
+ # Save element position
832
+ style["elements"][panel][element] = {
833
+ "type": element_type,
834
+ "position": position,
835
+ "snap_name": snap_name,
836
+ }
837
+
838
+ # For legends, also update legend_location for matplotlib compatibility
839
+ if element_type == "legend" and snap_name:
840
+ # Convert snap name to matplotlib loc format
841
+ loc_map = {
842
+ "upper left": "upper left",
843
+ "upper center": "upper center",
844
+ "upper right": "upper right",
845
+ "center left": "center left",
846
+ "center": "center",
847
+ "center right": "center right",
848
+ "lower left": "lower left",
849
+ "lower center": "lower center",
850
+ "lower right": "lower right",
851
+ }
852
+ if snap_name in loc_map:
853
+ if "legend" not in style:
854
+ style["legend"] = {}
855
+ style["legend"]["location"] = loc_map[snap_name]
856
+
857
+ bundle.write_json("style.json", style)
858
+
859
+ return jsonify(
860
+ {
861
+ "success": True,
862
+ "element": element,
863
+ "position": position,
864
+ "snap_name": snap_name,
865
+ }
866
+ )
867
+
868
+ except Exception as e:
869
+ import traceback
870
+
871
+ return jsonify(
872
+ {
873
+ "success": False,
874
+ "error": str(e),
875
+ "traceback": traceback.format_exc(),
876
+ }
877
+ )
878
+
879
+ @app.route("/export", methods=["POST"])
880
+ def export_figure():
881
+ """Export composed figure to various formats and update figz bundle."""
882
+ try:
883
+ data = request.get_json()
884
+ formats = data.get("formats", ["png", "svg"])
885
+
886
+ if not editor.panel_info:
887
+ return jsonify(
888
+ {"success": False, "error": "No panel info available"}
889
+ )
890
+
891
+ bundle_path = editor.panel_info.get("bundle_path")
892
+ if not bundle_path:
893
+ return jsonify(
894
+ {"success": False, "error": "Bundle path not available"}
895
+ )
896
+
897
+ import io
898
+ from pathlib import Path
899
+
900
+ import matplotlib
901
+
902
+ from scitex.fts._bundle._zipbundle import ZipBundle
903
+
904
+ matplotlib.use("Agg")
905
+ import matplotlib.pyplot as plt
906
+ import numpy as np
907
+ from PIL import Image
908
+
909
+ figure_name = Path(bundle_path).stem
910
+ dpi = data.get("dpi", 150)
911
+
912
+ with ZipBundle(bundle_path, mode="a") as bundle:
913
+ # Read spec for figure size and panel positions
914
+ try:
915
+ spec = bundle.read_json("spec.json")
916
+ except:
917
+ spec = {}
918
+
919
+ # Get figure dimensions
920
+ fig_width_mm = 180
921
+ fig_height_mm = 120
922
+ if "figure" in spec:
923
+ fig_info = spec.get("figure", {})
924
+ styles = fig_info.get("styles", {})
925
+ size = styles.get("size", {})
926
+ fig_width_mm = size.get("width_mm", 180)
927
+ fig_height_mm = size.get("height_mm", 120)
928
+
929
+ # Convert mm to inches
930
+ fig_width_in = fig_width_mm / 25.4
931
+ fig_height_in = fig_height_mm / 25.4
932
+
933
+ # Create figure with white background
934
+ fig = plt.figure(
935
+ figsize=(fig_width_in, fig_height_in),
936
+ dpi=dpi,
937
+ facecolor="white",
938
+ )
939
+
940
+ # Get panels from spec or editor.panel_info
941
+ panels_spec = spec.get("panels", [])
942
+
943
+ # Compose panels onto figure
944
+ for panel_spec in panels_spec:
945
+ panel_id = panel_spec.get("id", "")
946
+ pltz_name = panel_spec.get("plot", "")
947
+
948
+ # Get position and size from spec
949
+ pos = panel_spec.get("position", {})
950
+ size = panel_spec.get("size", {})
951
+
952
+ x_mm = pos.get("x_mm", 0)
953
+ y_mm = pos.get("y_mm", 0)
954
+ w_mm = size.get("width_mm", 60)
955
+ h_mm = size.get("height_mm", 40)
956
+
957
+ # Convert to figure coordinates (0-1)
958
+ x_frac = x_mm / fig_width_mm
959
+ y_frac = 1 - (y_mm + h_mm) / fig_height_mm # Flip Y
960
+ w_frac = w_mm / fig_width_mm
961
+ h_frac = h_mm / fig_height_mm
962
+
963
+ # Try to read panel image from pltz exports
964
+ img_loaded = False
965
+ for pltz_path in [
966
+ f"{panel_id}.pltz",
967
+ pltz_name.replace(".d", ""),
968
+ ]:
969
+ if img_loaded:
970
+ break
971
+ try:
972
+ # Read pltz as nested bundle
973
+ pltz_bytes = bundle.read_bytes(pltz_path)
974
+ import tempfile
975
+
976
+ with tempfile.NamedTemporaryFile(
977
+ suffix=".pltz", delete=False
978
+ ) as tmp:
979
+ tmp.write(pltz_bytes)
980
+ tmp_path = tmp.name
981
+ try:
982
+ with ZipBundle(tmp_path, mode="r") as pltz_bundle:
983
+ # Try various preview paths
984
+ for preview_path in [
985
+ "exports/preview.png",
986
+ "preview.png",
987
+ f"exports/{panel_id}.png",
988
+ ]:
989
+ try:
990
+ img_data = pltz_bundle.read_bytes(
991
+ preview_path
992
+ )
993
+ img = Image.open(io.BytesIO(img_data))
994
+ img_array = np.array(img)
995
+
996
+ # Create axes and add image
997
+ ax = fig.add_axes(
998
+ [x_frac, y_frac, w_frac, h_frac]
999
+ )
1000
+ ax.imshow(img_array)
1001
+ ax.axis("off")
1002
+ img_loaded = True
1003
+ break
1004
+ except:
1005
+ continue
1006
+ finally:
1007
+ import os
1008
+
1009
+ os.unlink(tmp_path)
1010
+ except Exception as e:
1011
+ print(f"Could not load pltz {pltz_path}: {e}")
1012
+ continue
1013
+
1014
+ exported = {}
1015
+
1016
+ for fmt in formats:
1017
+ buf = io.BytesIO()
1018
+ if fmt in ["png", "jpeg", "jpg"]:
1019
+ fig.savefig(
1020
+ buf,
1021
+ format="png" if fmt == "png" else "jpeg",
1022
+ dpi=dpi,
1023
+ bbox_inches="tight",
1024
+ facecolor="white",
1025
+ pad_inches=0.02,
1026
+ )
1027
+ elif fmt == "svg":
1028
+ fig.savefig(
1029
+ buf, format="svg", bbox_inches="tight", pad_inches=0.02
1030
+ )
1031
+ elif fmt == "pdf":
1032
+ fig.savefig(
1033
+ buf, format="pdf", bbox_inches="tight", pad_inches=0.02
1034
+ )
1035
+ else:
1036
+ continue
1037
+
1038
+ buf.seek(0)
1039
+ content = buf.read()
1040
+
1041
+ # Save to exports/ directory in bundle
1042
+ export_path = f"exports/{figure_name}.{fmt}"
1043
+ bundle.write_bytes(export_path, content)
1044
+ exported[fmt] = export_path
1045
+
1046
+ plt.close(fig)
1047
+
1048
+ return jsonify(
1049
+ {
1050
+ "success": True,
1051
+ "exported": exported,
1052
+ "bundle_path": str(bundle_path),
1053
+ }
1054
+ )
1055
+
1056
+ except Exception as e:
1057
+ import traceback
1058
+
1059
+ return jsonify(
1060
+ {
1061
+ "success": False,
1062
+ "error": str(e),
1063
+ "traceback": traceback.format_exc(),
1064
+ }
1065
+ )
1066
+
1067
+ @app.route("/download/<fmt>")
1068
+ def download_figure(fmt):
1069
+ """Download figure in specified format."""
1070
+ try:
1071
+ import io
1072
+ from pathlib import Path
1073
+
1074
+ from flask import send_file
1075
+
1076
+ mime_types = {
1077
+ "png": "image/png",
1078
+ "jpeg": "image/jpeg",
1079
+ "jpg": "image/jpeg",
1080
+ "svg": "image/svg+xml",
1081
+ "pdf": "application/pdf",
1082
+ }
1083
+
1084
+ if fmt not in mime_types:
1085
+ return f"Unsupported format: {fmt}", 400
1086
+
1087
+ # For figz bundles, download the composed figure
1088
+ if editor.panel_info:
1089
+ bundle_path = editor.panel_info.get("bundle_path")
1090
+ figz_dir = editor.panel_info.get("figz_dir")
1091
+ figure_name = (
1092
+ Path(bundle_path).stem
1093
+ if bundle_path
1094
+ else (
1095
+ Path(figz_dir).stem.replace(".figz.d", "")
1096
+ if figz_dir
1097
+ else "figure"
1098
+ )
1099
+ )
1100
+
1101
+ if bundle_path or figz_dir:
1102
+ import matplotlib
1103
+ import numpy as np
1104
+ from PIL import Image
1105
+
1106
+ from scitex.fts._bundle._zipbundle import ZipBundle
1107
+
1108
+ matplotlib.use("Agg")
1109
+ import json as json_module
1110
+
1111
+ import matplotlib.pyplot as plt
1112
+
1113
+ # Always compose on-demand to ensure current panel state
1114
+ # (existing exports in bundle may be stale or blank)
1115
+
1116
+ # Read spec.json and layout.json for position overrides
1117
+ spec = {}
1118
+ layout_overrides = {}
1119
+ if bundle_path:
1120
+ try:
1121
+ with ZipBundle(bundle_path, mode="r") as bundle:
1122
+ spec = bundle.read_json("spec.json")
1123
+ try:
1124
+ layout_overrides = bundle.read_json(
1125
+ "layout.json"
1126
+ )
1127
+ except:
1128
+ pass
1129
+ except:
1130
+ pass
1131
+ elif figz_dir:
1132
+ spec_path = Path(figz_dir) / "spec.json"
1133
+ if spec_path.exists():
1134
+ with open(spec_path) as f:
1135
+ spec = json_module.load(f)
1136
+ layout_path = Path(figz_dir) / "layout.json"
1137
+ if layout_path.exists():
1138
+ with open(layout_path) as f:
1139
+ layout_overrides = json_module.load(f)
1140
+
1141
+ # Also check in-memory layout overrides (most current)
1142
+ if editor.panel_info and editor.panel_info.get("layout"):
1143
+ layout_overrides = editor.panel_info.get("layout", {})
1144
+
1145
+ # Get figure dimensions
1146
+ fig_width_mm = 180
1147
+ fig_height_mm = 120
1148
+ if "figure" in spec:
1149
+ fig_info = spec.get("figure", {})
1150
+ styles = fig_info.get("styles", {})
1151
+ size = styles.get("size", {})
1152
+ fig_width_mm = size.get("width_mm", 180)
1153
+ fig_height_mm = size.get("height_mm", 120)
1154
+
1155
+ fig_width_in = fig_width_mm / 25.4
1156
+ fig_height_in = fig_height_mm / 25.4
1157
+
1158
+ dpi = 150 if fmt in ["jpeg", "jpg"] else 300
1159
+ fig = plt.figure(
1160
+ figsize=(fig_width_in, fig_height_in),
1161
+ dpi=dpi,
1162
+ facecolor="white",
1163
+ )
1164
+
1165
+ # Compose panels
1166
+ panels_spec = spec.get("panels", [])
1167
+ panel_paths = editor.panel_info.get("panel_paths", [])
1168
+ panel_is_zip = editor.panel_info.get("panel_is_zip", [])
1169
+
1170
+ for panel_spec in panels_spec:
1171
+ panel_id = panel_spec.get("id", "")
1172
+ pos = panel_spec.get("position", {})
1173
+ size = panel_spec.get("size", {})
1174
+
1175
+ # Skip overview/auxiliary panels (only compose main panels A-Z)
1176
+ panel_id_lower = panel_id.lower()
1177
+ if any(
1178
+ skip in panel_id_lower
1179
+ for skip in ["overview", "thumb", "preview", "aux"]
1180
+ ):
1181
+ continue
1182
+
1183
+ # Find panel path first (needed to check layout_overrides)
1184
+ panel_path = None
1185
+ is_zip = False
1186
+ panel_name = None
1187
+ for idx, pp in enumerate(panel_paths):
1188
+ pp_name = Path(pp).stem.replace(".pltz", "")
1189
+ # Match exact name, or name contains panel_id pattern
1190
+ # e.g., "panel_A_twinx" matches panel_id "A"
1191
+ if (
1192
+ pp_name == panel_id
1193
+ or pp_name.startswith(f"panel_{panel_id}_")
1194
+ or pp_name.startswith(f"panel_{panel_id}.")
1195
+ or pp_name == f"panel_{panel_id}"
1196
+ or pp_name == panel_id
1197
+ or f"_{panel_id}_" in pp_name
1198
+ or pp_name.endswith(f"_{panel_id}")
1199
+ ):
1200
+ panel_path = pp
1201
+ panel_name = Path(
1202
+ pp
1203
+ ).name # e.g., "panel_A_twinx.pltz"
1204
+ is_zip = (
1205
+ panel_is_zip[idx]
1206
+ if idx < len(panel_is_zip)
1207
+ else False
1208
+ )
1209
+ break
1210
+
1211
+ if not panel_path:
1212
+ print(
1213
+ f"Could not find panel path for id={panel_id}, available: {[Path(p).stem for p in panel_paths]}"
1214
+ )
1215
+ continue
1216
+
1217
+ # Check for layout overrides (from layout.json or in-memory)
1218
+ override = layout_overrides.get(panel_name, {})
1219
+ override_pos = override.get("position", {})
1220
+ override_size = override.get("size", {})
1221
+
1222
+ # Use override positions if available, otherwise use spec
1223
+ x_mm = override_pos.get("x_mm", pos.get("x_mm", 0))
1224
+ y_mm = override_pos.get("y_mm", pos.get("y_mm", 0))
1225
+ w_mm = override_size.get(
1226
+ "width_mm", size.get("width_mm", 60)
1227
+ )
1228
+ h_mm = override_size.get(
1229
+ "height_mm", size.get("height_mm", 40)
1230
+ )
1231
+
1232
+ x_frac = x_mm / fig_width_mm
1233
+ y_frac = 1 - (y_mm + h_mm) / fig_height_mm
1234
+ w_frac = w_mm / fig_width_mm
1235
+ h_frac = h_mm / fig_height_mm
1236
+
1237
+ # Load panel preview image
1238
+ try:
1239
+ img_loaded = False
1240
+ # Exclusion patterns for preview selection
1241
+ exclude_patterns = [
1242
+ "hitmap",
1243
+ "overview",
1244
+ "thumb",
1245
+ "preview",
1246
+ ]
1247
+
1248
+ if is_zip:
1249
+ with ZipBundle(panel_path, mode="r") as pltz_bundle:
1250
+ # Find PNG in exports (exclude hitmap, overview, thumbnails)
1251
+ import zipfile
1252
+
1253
+ with zipfile.ZipFile(panel_path, "r") as zf:
1254
+ png_files = [
1255
+ n
1256
+ for n in zf.namelist()
1257
+ if n.endswith(".png")
1258
+ and "exports/" in n
1259
+ and not any(
1260
+ p in n.lower()
1261
+ for p in exclude_patterns
1262
+ )
1263
+ ]
1264
+ if png_files:
1265
+ # Use first matching PNG
1266
+ preview_path = png_files[0]
1267
+ # Extract the path relative to .d directory
1268
+ if ".pltz.d/" in preview_path:
1269
+ preview_path = preview_path.split(
1270
+ ".pltz.d/"
1271
+ )[-1]
1272
+ try:
1273
+ img_data = pltz_bundle.read_bytes(
1274
+ preview_path
1275
+ )
1276
+ img = Image.open(
1277
+ io.BytesIO(img_data)
1278
+ )
1279
+ ax = fig.add_axes(
1280
+ [x_frac, y_frac, w_frac, h_frac]
1281
+ )
1282
+ ax.imshow(np.array(img))
1283
+ ax.axis("off")
1284
+ img_loaded = True
1285
+ except Exception as e:
1286
+ print(
1287
+ f"Could not read {preview_path}: {e}"
1288
+ )
1289
+ else:
1290
+ # Directory-based pltz
1291
+ pltz_dir = Path(panel_path)
1292
+ exports_dir = pltz_dir / "exports"
1293
+ if exports_dir.exists():
1294
+ for png_file in exports_dir.glob("*.png"):
1295
+ name_lower = png_file.name.lower()
1296
+ if not any(
1297
+ p in name_lower
1298
+ for p in exclude_patterns
1299
+ ):
1300
+ img = Image.open(png_file)
1301
+ ax = fig.add_axes(
1302
+ [x_frac, y_frac, w_frac, h_frac]
1303
+ )
1304
+ ax.imshow(np.array(img))
1305
+ ax.axis("off")
1306
+ img_loaded = True
1307
+ break
1308
+ if not img_loaded:
1309
+ print(f"No preview found for panel {panel_id}")
1310
+ except Exception as e:
1311
+ print(f"Could not load panel {panel_id}: {e}")
1312
+
1313
+ # Draw panel letter
1314
+ if (
1315
+ panel_id and len(panel_id) <= 2
1316
+ ): # Only for short IDs like A, B, C...
1317
+ # Position letter at top-left corner of panel
1318
+ letter_x = x_frac + 0.01
1319
+ letter_y = y_frac + h_frac - 0.02
1320
+ fig.text(
1321
+ letter_x,
1322
+ letter_y,
1323
+ panel_id,
1324
+ fontsize=14,
1325
+ fontweight="bold",
1326
+ color="black",
1327
+ ha="left",
1328
+ va="top",
1329
+ transform=fig.transFigure,
1330
+ bbox=dict(
1331
+ boxstyle="square,pad=0.1",
1332
+ facecolor="white",
1333
+ edgecolor="none",
1334
+ alpha=0.8,
1335
+ ),
1336
+ )
1337
+
1338
+ buf = io.BytesIO()
1339
+ fig.savefig(
1340
+ buf,
1341
+ format=fmt if fmt != "jpg" else "jpeg",
1342
+ dpi=dpi,
1343
+ bbox_inches="tight",
1344
+ facecolor="white",
1345
+ pad_inches=0.02,
1346
+ )
1347
+ plt.close(fig)
1348
+ buf.seek(0)
1349
+
1350
+ return send_file(
1351
+ buf,
1352
+ mimetype=mime_types[fmt],
1353
+ as_attachment=True,
1354
+ download_name=f"{figure_name}.{fmt}",
1355
+ )
1356
+
1357
+ # For single pltz files, render from csv_data
1358
+ import matplotlib
1359
+
1360
+ from ._renderer import render_preview_with_bboxes
1361
+
1362
+ matplotlib.use("Agg")
1363
+ import matplotlib.pyplot as plt
1364
+
1365
+ figure_name = "figure"
1366
+ if editor.json_path:
1367
+ figure_name = Path(editor.json_path).stem
1368
+
1369
+ img_data, _, _ = render_preview_with_bboxes(
1370
+ editor.csv_data,
1371
+ editor.current_overrides,
1372
+ metadata=editor.metadata,
1373
+ dark_mode=False,
1374
+ )
1375
+
1376
+ if fmt == "png":
1377
+ import base64
1378
+
1379
+ content = base64.b64decode(img_data)
1380
+ buf = io.BytesIO(content)
1381
+ return send_file(
1382
+ buf,
1383
+ mimetype=mime_types[fmt],
1384
+ as_attachment=True,
1385
+ download_name=f"{figure_name}.{fmt}",
1386
+ )
1387
+
1388
+ # For other formats, re-render
1389
+ from ._plotter import plot_from_csv
1390
+
1391
+ fig, ax = plt.subplots(figsize=(8, 6))
1392
+ plot_from_csv(ax, editor.csv_data, editor.current_overrides)
1393
+
1394
+ buf = io.BytesIO()
1395
+ dpi = 150 if fmt in ["jpeg", "jpg"] else 300
1396
+ fig.savefig(
1397
+ buf,
1398
+ format=fmt if fmt != "jpg" else "jpeg",
1399
+ dpi=dpi,
1400
+ bbox_inches="tight",
1401
+ facecolor="white" if fmt in ["jpeg", "jpg"] else None,
1402
+ )
1403
+ plt.close(fig)
1404
+ buf.seek(0)
1405
+
1406
+ return send_file(
1407
+ buf,
1408
+ mimetype=mime_types[fmt],
1409
+ as_attachment=True,
1410
+ download_name=f"{figure_name}.{fmt}",
1411
+ )
1412
+
1413
+ except Exception as e:
1414
+ import traceback
1415
+
1416
+ return f"Error: {str(e)}\n{traceback.format_exc()}", 500
1417
+
1418
+ @app.route("/download_figz")
1419
+ def download_figz():
1420
+ """Download as figz bundle (re-editable format)."""
1421
+ try:
1422
+ if not editor.panel_info:
1423
+ return "No panel info available", 404
1424
+
1425
+ bundle_path = editor.panel_info.get("bundle_path")
1426
+ if not bundle_path:
1427
+ return "Bundle path not available", 404
1428
+
1429
+ from pathlib import Path
1430
+
1431
+ from flask import send_file
1432
+
1433
+ # Send the figz file directly (it's already a pltz-compatible format)
1434
+ return send_file(
1435
+ bundle_path,
1436
+ mimetype="application/zip",
1437
+ as_attachment=True,
1438
+ download_name=Path(bundle_path).name,
1439
+ )
1440
+
1441
+ except Exception as e:
1442
+ return str(e), 500
1443
+
1444
+ @app.route("/shutdown", methods=["POST"])
1445
+ def shutdown():
1446
+ """Shutdown the server."""
1447
+ func = request.environ.get("werkzeug.server.shutdown")
1448
+ if func is None:
1449
+ raise RuntimeError("Not running with Werkzeug Server")
1450
+ func()
1451
+ return jsonify({"status": "shutdown"})
1452
+
1453
+ @app.route("/stats")
1454
+ def stats():
1455
+ """Return statistical test results from figure metadata."""
1456
+ stats_data = editor.metadata.get("stats", [])
1457
+ stats_summary = editor.metadata.get("stats_summary", None)
1458
+ return jsonify(
1459
+ {
1460
+ "stats": stats_data,
1461
+ "stats_summary": stats_summary,
1462
+ "has_stats": len(stats_data) > 0,
1463
+ }
1464
+ )
1465
+
1466
+ # Open browser after short delay
1467
+ def open_browser():
1468
+ import time
1469
+
1470
+ time.sleep(0.5)
1471
+ webbrowser.open(f"http://127.0.0.1:{self.port}")
1472
+
1473
+ threading.Thread(target=open_browser, daemon=True).start()
1474
+
1475
+ print(f"Starting SciTeX Figure Editor at http://127.0.0.1:{self.port}")
1476
+ print("Press Ctrl+C to stop")
1477
+
1478
+ # Note: use_reloader=False because the reloader re-runs the entire script
1479
+ # which causes infinite loops when the demo generates figures
1480
+ # Templates are rebuilt on each page refresh anyway
1481
+ app.run(host="127.0.0.1", port=self.port, debug=False, use_reloader=False)
1482
+
1483
+
1484
+ def _extract_bboxes_from_metadata(
1485
+ metadata: Dict[str, Any],
1486
+ display_width: Optional[float] = None,
1487
+ display_height: Optional[float] = None,
1488
+ ) -> Dict[str, Any]:
1489
+ """Extract element bounding boxes from pltz metadata.
1490
+
1491
+ Builds bboxes from selectable_regions in the metadata for click detection.
1492
+ This allows the editor to highlight elements when clicked.
1493
+
1494
+ Coordinate system (new layered format):
1495
+ - selectable_regions bbox_px: Already in final image space (figure_px)
1496
+ - Display size: Actual displayed image size (PNG pixels or SVG viewBox)
1497
+ - Scale = display_size / figure_px (usually 1:1, but may differ for scaled display)
1498
+
1499
+ Parameters
1500
+ ----------
1501
+ metadata : dict
1502
+ The pltz JSON metadata containing selectable_regions
1503
+ display_width : float, optional
1504
+ Actual display image width (from PNG size or SVG viewBox)
1505
+ display_height : float, optional
1506
+ Actual display image height (from PNG size or SVG viewBox)
1507
+
1508
+ Returns
1509
+ -------
1510
+ dict
1511
+ Mapping of element IDs to their bounding box coordinates (in display pixels)
1512
+ """
1513
+ bboxes = {}
1514
+ selectable = metadata.get("selectable_regions", {})
1515
+
1516
+ # Figure dimensions from new layered format (bbox_px are in this space)
1517
+ figure_px = metadata.get("figure_px", [])
1518
+ if isinstance(figure_px, list) and len(figure_px) >= 2:
1519
+ fig_width = figure_px[0]
1520
+ fig_height = figure_px[1]
1521
+ else:
1522
+ # Fallback for old format: try hit_regions.path_data.figure
1523
+ hit_regions = metadata.get("hit_regions", {})
1524
+ path_data = hit_regions.get("path_data", {})
1525
+ orig_fig = path_data.get("figure", {})
1526
+ fig_width = orig_fig.get("width_px", 944)
1527
+ fig_height = orig_fig.get("height_px", 803)
1528
+
1529
+ # Use actual display dimensions if provided, else use figure_px
1530
+ if display_width is None:
1531
+ display_width = fig_width
1532
+ if display_height is None:
1533
+ display_height = fig_height
1534
+
1535
+ # Scale factor: display / figure_px
1536
+ # Usually 1:1 since display is the same PNG, but may differ for scaled display
1537
+ scale_x = display_width / fig_width if fig_width > 0 else 1
1538
+ scale_y = display_height / fig_height if fig_height > 0 else 1
1539
+
1540
+ # Helper to convert coords to display pixels
1541
+ def to_display_bbox(bbox, is_list=True):
1542
+ """Convert bbox to display pixels (apply scaling if display != figure_px).
1543
+
1544
+ Parameters
1545
+ ----------
1546
+ bbox : list or dict
1547
+ Bbox coordinates [x0, y0, x1, y1] or dict with keys
1548
+ is_list : bool
1549
+ Whether bbox is a list (True) or dict (False)
1550
+ """
1551
+ if is_list:
1552
+ x0, y0, x1, y1 = bbox[0], bbox[1], bbox[2], bbox[3]
1553
+ else:
1554
+ x0 = bbox.get("x0", 0)
1555
+ y0 = bbox.get("y0", 0)
1556
+ x1 = bbox.get("x1", bbox.get("x0", 0) + bbox.get("width", 0))
1557
+ y1 = bbox.get("y1", bbox.get("y0", 0) + bbox.get("height", 0))
1558
+
1559
+ # Scale to display coords (usually 1:1)
1560
+ disp_x0 = x0 * scale_x
1561
+ disp_x1 = x1 * scale_x
1562
+ disp_y0 = y0 * scale_y
1563
+ disp_y1 = y1 * scale_y
1564
+
1565
+ return {
1566
+ "x0": disp_x0,
1567
+ "y0": disp_y0,
1568
+ "x1": disp_x1,
1569
+ "y1": disp_y1,
1570
+ "x": disp_x0,
1571
+ "y": disp_y0,
1572
+ "width": disp_x1 - disp_x0,
1573
+ "height": disp_y1 - disp_y0,
1574
+ }
1575
+
1576
+ # Extract from selectable_regions.axes
1577
+ axes_regions = selectable.get("axes", [])
1578
+ for ax_idx, ax in enumerate(axes_regions):
1579
+ ax_key = f"ax_{ax_idx:02d}"
1580
+
1581
+ # Title
1582
+ title = ax.get("title", {})
1583
+ if title and "bbox_px" in title:
1584
+ bbox_disp = to_display_bbox(title["bbox_px"])
1585
+ bboxes[f"{ax_key}_title"] = {
1586
+ **bbox_disp,
1587
+ "type": "title",
1588
+ "text": title.get("text", ""),
1589
+ }
1590
+
1591
+ # X label
1592
+ xlabel = ax.get("xlabel", {})
1593
+ if xlabel and "bbox_px" in xlabel:
1594
+ bbox_disp = to_display_bbox(xlabel["bbox_px"])
1595
+ bboxes[f"{ax_key}_xlabel"] = {
1596
+ **bbox_disp,
1597
+ "type": "xlabel",
1598
+ "text": xlabel.get("text", ""),
1599
+ }
1600
+
1601
+ # Y label
1602
+ ylabel = ax.get("ylabel", {})
1603
+ if ylabel and "bbox_px" in ylabel:
1604
+ bbox_disp = to_display_bbox(ylabel["bbox_px"])
1605
+ bboxes[f"{ax_key}_ylabel"] = {
1606
+ **bbox_disp,
1607
+ "type": "ylabel",
1608
+ "text": ylabel.get("text", ""),
1609
+ }
1610
+
1611
+ # Legend
1612
+ legend = ax.get("legend", {})
1613
+ if legend and "bbox_px" in legend:
1614
+ bbox_disp = to_display_bbox(legend["bbox_px"])
1615
+ bboxes[f"{ax_key}_legend"] = {
1616
+ **bbox_disp,
1617
+ "type": "legend",
1618
+ }
1619
+
1620
+ # X-axis spine
1621
+ xaxis = ax.get("xaxis", {})
1622
+ if xaxis:
1623
+ spine = xaxis.get("spine", {})
1624
+ if spine and "bbox_px" in spine:
1625
+ bbox_disp = to_display_bbox(spine["bbox_px"])
1626
+ bboxes[f"{ax_key}_xaxis_spine"] = {
1627
+ **bbox_disp,
1628
+ "type": "xaxis",
1629
+ }
1630
+
1631
+ # Y-axis spine
1632
+ yaxis = ax.get("yaxis", {})
1633
+ if yaxis:
1634
+ spine = yaxis.get("spine", {})
1635
+ if spine and "bbox_px" in spine:
1636
+ bbox_disp = to_display_bbox(spine["bbox_px"])
1637
+ bboxes[f"{ax_key}_yaxis_spine"] = {
1638
+ **bbox_disp,
1639
+ "type": "yaxis",
1640
+ }
1641
+
1642
+ # Extract traces from artists (top-level in new format, or hit_regions.path_data in old)
1643
+ artists = metadata.get("artists", [])
1644
+ if not artists:
1645
+ # Fallback for old format
1646
+ hit_regions = metadata.get("hit_regions", {})
1647
+ path_data = hit_regions.get("path_data", {})
1648
+ artists = path_data.get("artists", [])
1649
+
1650
+ for artist in artists:
1651
+ artist_id = artist.get("id", 0)
1652
+ artist_type = artist.get("type", "line")
1653
+ bbox_px = artist.get("bbox_px", {})
1654
+ if bbox_px:
1655
+ bbox_disp = to_display_bbox(bbox_px, is_list=False)
1656
+ trace_entry = {
1657
+ **bbox_disp,
1658
+ "type": artist_type,
1659
+ "label": artist.get("label", f"Trace {artist_id}"),
1660
+ "element_type": artist_type,
1661
+ }
1662
+
1663
+ # Include scaled path points for line proximity detection
1664
+ path_px = artist.get("path_px", [])
1665
+ if path_px:
1666
+ scaled_points = [
1667
+ [pt[0] * scale_x, pt[1] * scale_y] for pt in path_px if len(pt) >= 2
1668
+ ]
1669
+ trace_entry["points"] = scaled_points
1670
+
1671
+ bboxes[f"trace_{artist_id}"] = trace_entry
1672
+
1673
+ # Add metadata for JavaScript to understand the coordinate system
1674
+ bboxes["_meta"] = {
1675
+ "display_width": display_width,
1676
+ "display_height": display_height,
1677
+ "figure_px_width": fig_width,
1678
+ "figure_px_height": fig_height,
1679
+ "scale_x": scale_x,
1680
+ "scale_y": scale_y,
1681
+ # Note: With new layered format, bbox_px are already in final image space
1682
+ # so scale is typically 1:1 (unless display is resized)
1683
+ }
1684
+
1685
+ return bboxes
1686
+
1687
+
1688
+ # EOF