scitex 2.7.0__py3-none-any.whl → 2.8.1__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 (355) hide show
  1. scitex/__init__.py +6 -2
  2. scitex/__version__.py +1 -1
  3. scitex/audio/README.md +52 -0
  4. scitex/audio/__init__.py +384 -0
  5. scitex/audio/__main__.py +129 -0
  6. scitex/audio/_tts.py +334 -0
  7. scitex/audio/engines/__init__.py +44 -0
  8. scitex/audio/engines/base.py +275 -0
  9. scitex/audio/engines/elevenlabs_engine.py +143 -0
  10. scitex/audio/engines/gtts_engine.py +162 -0
  11. scitex/audio/engines/pyttsx3_engine.py +131 -0
  12. scitex/audio/mcp_server.py +757 -0
  13. scitex/bridge/_helpers.py +1 -1
  14. scitex/bridge/_plt_vis.py +1 -1
  15. scitex/bridge/_stats_vis.py +1 -1
  16. scitex/dev/plt/__init__.py +272 -0
  17. scitex/dev/plt/plot_mpl_axhline.py +28 -0
  18. scitex/dev/plt/plot_mpl_axhspan.py +28 -0
  19. scitex/dev/plt/plot_mpl_axvline.py +28 -0
  20. scitex/dev/plt/plot_mpl_axvspan.py +28 -0
  21. scitex/dev/plt/plot_mpl_bar.py +29 -0
  22. scitex/dev/plt/plot_mpl_barh.py +29 -0
  23. scitex/dev/plt/plot_mpl_boxplot.py +28 -0
  24. scitex/dev/plt/plot_mpl_contour.py +31 -0
  25. scitex/dev/plt/plot_mpl_contourf.py +31 -0
  26. scitex/dev/plt/plot_mpl_errorbar.py +30 -0
  27. scitex/dev/plt/plot_mpl_eventplot.py +28 -0
  28. scitex/dev/plt/plot_mpl_fill.py +30 -0
  29. scitex/dev/plt/plot_mpl_fill_between.py +31 -0
  30. scitex/dev/plt/plot_mpl_hexbin.py +28 -0
  31. scitex/dev/plt/plot_mpl_hist.py +28 -0
  32. scitex/dev/plt/plot_mpl_hist2d.py +28 -0
  33. scitex/dev/plt/plot_mpl_imshow.py +29 -0
  34. scitex/dev/plt/plot_mpl_pcolormesh.py +31 -0
  35. scitex/dev/plt/plot_mpl_pie.py +29 -0
  36. scitex/dev/plt/plot_mpl_plot.py +29 -0
  37. scitex/dev/plt/plot_mpl_quiver.py +31 -0
  38. scitex/dev/plt/plot_mpl_scatter.py +28 -0
  39. scitex/dev/plt/plot_mpl_stackplot.py +31 -0
  40. scitex/dev/plt/plot_mpl_stem.py +29 -0
  41. scitex/dev/plt/plot_mpl_step.py +29 -0
  42. scitex/dev/plt/plot_mpl_violinplot.py +28 -0
  43. scitex/dev/plt/plot_sns_barplot.py +29 -0
  44. scitex/dev/plt/plot_sns_boxplot.py +29 -0
  45. scitex/dev/plt/plot_sns_heatmap.py +28 -0
  46. scitex/dev/plt/plot_sns_histplot.py +29 -0
  47. scitex/dev/plt/plot_sns_kdeplot.py +29 -0
  48. scitex/dev/plt/plot_sns_lineplot.py +31 -0
  49. scitex/dev/plt/plot_sns_scatterplot.py +29 -0
  50. scitex/dev/plt/plot_sns_stripplot.py +29 -0
  51. scitex/dev/plt/plot_sns_swarmplot.py +29 -0
  52. scitex/dev/plt/plot_sns_violinplot.py +29 -0
  53. scitex/dev/plt/plot_stx_bar.py +29 -0
  54. scitex/dev/plt/plot_stx_barh.py +29 -0
  55. scitex/dev/plt/plot_stx_box.py +28 -0
  56. scitex/dev/plt/plot_stx_boxplot.py +28 -0
  57. scitex/dev/plt/plot_stx_conf_mat.py +28 -0
  58. scitex/dev/plt/plot_stx_contour.py +31 -0
  59. scitex/dev/plt/plot_stx_ecdf.py +28 -0
  60. scitex/dev/plt/plot_stx_errorbar.py +30 -0
  61. scitex/dev/plt/plot_stx_fill_between.py +31 -0
  62. scitex/dev/plt/plot_stx_fillv.py +28 -0
  63. scitex/dev/plt/plot_stx_heatmap.py +28 -0
  64. scitex/dev/plt/plot_stx_image.py +28 -0
  65. scitex/dev/plt/plot_stx_imshow.py +28 -0
  66. scitex/dev/plt/plot_stx_joyplot.py +28 -0
  67. scitex/dev/plt/plot_stx_kde.py +28 -0
  68. scitex/dev/plt/plot_stx_line.py +28 -0
  69. scitex/dev/plt/plot_stx_mean_ci.py +28 -0
  70. scitex/dev/plt/plot_stx_mean_std.py +28 -0
  71. scitex/dev/plt/plot_stx_median_iqr.py +28 -0
  72. scitex/dev/plt/plot_stx_raster.py +28 -0
  73. scitex/dev/plt/plot_stx_rectangle.py +28 -0
  74. scitex/dev/plt/plot_stx_scatter.py +29 -0
  75. scitex/dev/plt/plot_stx_shaded_line.py +29 -0
  76. scitex/dev/plt/plot_stx_violin.py +28 -0
  77. scitex/dev/plt/plot_stx_violinplot.py +28 -0
  78. scitex/diagram/README.md +197 -0
  79. scitex/diagram/__init__.py +48 -0
  80. scitex/diagram/_compile.py +312 -0
  81. scitex/diagram/_diagram.py +355 -0
  82. scitex/diagram/_presets.py +173 -0
  83. scitex/diagram/_schema.py +182 -0
  84. scitex/diagram/_split.py +278 -0
  85. scitex/fig/__init__.py +352 -0
  86. scitex/{vis → fig}/backend/_parser.py +1 -1
  87. scitex/{vis → fig}/canvas.py +1 -1
  88. scitex/{vis → fig}/editor/__init__.py +5 -2
  89. scitex/{vis → fig}/editor/_dearpygui_editor.py +1 -1
  90. scitex/{vis → fig}/editor/_defaults.py +70 -5
  91. scitex/{vis → fig}/editor/_mpl_editor.py +1 -1
  92. scitex/{vis → fig}/editor/_qt_editor.py +182 -2
  93. scitex/{vis → fig}/editor/_tkinter_editor.py +1 -1
  94. scitex/fig/editor/edit/__init__.py +50 -0
  95. scitex/fig/editor/edit/backend_detector.py +109 -0
  96. scitex/fig/editor/edit/bundle_resolver.py +240 -0
  97. scitex/fig/editor/edit/editor_launcher.py +239 -0
  98. scitex/fig/editor/edit/manual_handler.py +53 -0
  99. scitex/fig/editor/edit/panel_loader.py +232 -0
  100. scitex/fig/editor/edit/path_resolver.py +67 -0
  101. scitex/fig/editor/flask_editor/_bbox.py +1299 -0
  102. scitex/fig/editor/flask_editor/_core.py +1429 -0
  103. scitex/{vis → fig}/editor/flask_editor/_plotter.py +38 -4
  104. scitex/fig/editor/flask_editor/_renderer.py +813 -0
  105. scitex/fig/editor/flask_editor/static/css/base/reset.css +41 -0
  106. scitex/fig/editor/flask_editor/static/css/base/typography.css +16 -0
  107. scitex/fig/editor/flask_editor/static/css/base/variables.css +85 -0
  108. scitex/fig/editor/flask_editor/static/css/components/buttons.css +217 -0
  109. scitex/fig/editor/flask_editor/static/css/components/context-menu.css +93 -0
  110. scitex/fig/editor/flask_editor/static/css/components/dropdown.css +57 -0
  111. scitex/fig/editor/flask_editor/static/css/components/forms.css +112 -0
  112. scitex/fig/editor/flask_editor/static/css/components/modal.css +59 -0
  113. scitex/fig/editor/flask_editor/static/css/components/sections.css +212 -0
  114. scitex/fig/editor/flask_editor/static/css/features/canvas.css +176 -0
  115. scitex/fig/editor/flask_editor/static/css/features/element-inspector.css +190 -0
  116. scitex/fig/editor/flask_editor/static/css/features/loading.css +59 -0
  117. scitex/fig/editor/flask_editor/static/css/features/overlay.css +45 -0
  118. scitex/fig/editor/flask_editor/static/css/features/panel-grid.css +95 -0
  119. scitex/fig/editor/flask_editor/static/css/features/selection.css +101 -0
  120. scitex/fig/editor/flask_editor/static/css/features/statistics.css +138 -0
  121. scitex/fig/editor/flask_editor/static/css/index.css +31 -0
  122. scitex/fig/editor/flask_editor/static/css/layout/container.css +7 -0
  123. scitex/fig/editor/flask_editor/static/css/layout/controls.css +56 -0
  124. scitex/fig/editor/flask_editor/static/css/layout/preview.css +78 -0
  125. scitex/fig/editor/flask_editor/static/js/alignment/axis.js +314 -0
  126. scitex/fig/editor/flask_editor/static/js/alignment/basic.js +107 -0
  127. scitex/fig/editor/flask_editor/static/js/alignment/distribute.js +54 -0
  128. scitex/fig/editor/flask_editor/static/js/canvas/canvas.js +172 -0
  129. scitex/fig/editor/flask_editor/static/js/canvas/dragging.js +258 -0
  130. scitex/fig/editor/flask_editor/static/js/canvas/resize.js +48 -0
  131. scitex/fig/editor/flask_editor/static/js/canvas/selection.js +71 -0
  132. scitex/fig/editor/flask_editor/static/js/core/api.js +288 -0
  133. scitex/fig/editor/flask_editor/static/js/core/state.js +143 -0
  134. scitex/fig/editor/flask_editor/static/js/core/utils.js +245 -0
  135. scitex/fig/editor/flask_editor/static/js/dev/element-inspector.js +992 -0
  136. scitex/fig/editor/flask_editor/static/js/editor/bbox.js +339 -0
  137. scitex/fig/editor/flask_editor/static/js/editor/element-drag.js +286 -0
  138. scitex/fig/editor/flask_editor/static/js/editor/overlay.js +371 -0
  139. scitex/fig/editor/flask_editor/static/js/editor/preview.js +293 -0
  140. scitex/fig/editor/flask_editor/static/js/main.js +426 -0
  141. scitex/fig/editor/flask_editor/static/js/shortcuts/context-menu.js +152 -0
  142. scitex/fig/editor/flask_editor/static/js/shortcuts/keyboard.js +265 -0
  143. scitex/fig/editor/flask_editor/static/js/ui/controls.js +184 -0
  144. scitex/fig/editor/flask_editor/static/js/ui/download.js +57 -0
  145. scitex/fig/editor/flask_editor/static/js/ui/help.js +100 -0
  146. scitex/fig/editor/flask_editor/static/js/ui/theme.js +34 -0
  147. scitex/fig/editor/flask_editor/templates/__init__.py +123 -0
  148. scitex/fig/editor/flask_editor/templates/_html.py +852 -0
  149. scitex/fig/editor/flask_editor/templates/_scripts.py +4933 -0
  150. scitex/fig/editor/flask_editor/templates/_styles.py +1658 -0
  151. scitex/{vis → fig}/io/__init__.py +13 -1
  152. scitex/fig/io/_bundle.py +1058 -0
  153. scitex/{vis → fig}/io/_canvas.py +1 -1
  154. scitex/{vis → fig}/io/_data.py +1 -1
  155. scitex/{vis → fig}/io/_export.py +1 -1
  156. scitex/{vis → fig}/io/_load.py +1 -1
  157. scitex/{vis → fig}/io/_panel.py +1 -1
  158. scitex/{vis → fig}/io/_save.py +1 -1
  159. scitex/{vis → fig}/model/__init__.py +1 -1
  160. scitex/{vis → fig}/model/_annotations.py +1 -1
  161. scitex/{vis → fig}/model/_axes.py +1 -1
  162. scitex/{vis → fig}/model/_figure.py +1 -1
  163. scitex/{vis → fig}/model/_guides.py +1 -1
  164. scitex/{vis → fig}/model/_plot.py +1 -1
  165. scitex/{vis → fig}/model/_styles.py +1 -1
  166. scitex/{vis → fig}/utils/__init__.py +1 -1
  167. scitex/io/__init__.py +22 -26
  168. scitex/io/_bundle.py +493 -0
  169. scitex/io/_flush.py +5 -2
  170. scitex/io/_load.py +98 -0
  171. scitex/io/_load_modules/_H5Explorer.py +5 -2
  172. scitex/io/_load_modules/_canvas.py +2 -2
  173. scitex/io/_load_modules/_image.py +3 -4
  174. scitex/io/_load_modules/_txt.py +4 -2
  175. scitex/io/_metadata.py +34 -324
  176. scitex/io/_metadata_modules/__init__.py +46 -0
  177. scitex/io/_metadata_modules/_embed.py +70 -0
  178. scitex/io/_metadata_modules/_read.py +64 -0
  179. scitex/io/_metadata_modules/_utils.py +79 -0
  180. scitex/io/_metadata_modules/embed_metadata_jpeg.py +74 -0
  181. scitex/io/_metadata_modules/embed_metadata_pdf.py +53 -0
  182. scitex/io/_metadata_modules/embed_metadata_png.py +26 -0
  183. scitex/io/_metadata_modules/embed_metadata_svg.py +62 -0
  184. scitex/io/_metadata_modules/read_metadata_jpeg.py +57 -0
  185. scitex/io/_metadata_modules/read_metadata_pdf.py +51 -0
  186. scitex/io/_metadata_modules/read_metadata_png.py +39 -0
  187. scitex/io/_metadata_modules/read_metadata_svg.py +44 -0
  188. scitex/io/_qr_utils.py +5 -3
  189. scitex/io/_save.py +548 -30
  190. scitex/io/_save_modules/_canvas.py +3 -3
  191. scitex/io/_save_modules/_image.py +5 -9
  192. scitex/io/_save_modules/_tex.py +7 -4
  193. scitex/io/_zip_bundle.py +439 -0
  194. scitex/io/utils/h5_to_zarr.py +11 -9
  195. scitex/msword/__init__.py +255 -0
  196. scitex/msword/profiles.py +357 -0
  197. scitex/msword/reader.py +753 -0
  198. scitex/msword/utils.py +289 -0
  199. scitex/msword/writer.py +362 -0
  200. scitex/plt/__init__.py +5 -2
  201. scitex/plt/_subplots/_AxesWrapper.py +6 -6
  202. scitex/plt/_subplots/_AxisWrapper.py +15 -9
  203. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/__init__.py +36 -0
  204. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_labels.py +264 -0
  205. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_metadata.py +213 -0
  206. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_visual.py +128 -0
  207. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/__init__.py +59 -0
  208. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_base.py +34 -0
  209. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_scientific.py +593 -0
  210. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_statistical.py +654 -0
  211. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_stx_aliases.py +527 -0
  212. scitex/plt/_subplots/_AxisWrapperMixins/_RawMatplotlibMixin.py +321 -0
  213. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/__init__.py +33 -0
  214. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/_base.py +152 -0
  215. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/_wrappers.py +600 -0
  216. scitex/plt/_subplots/_AxisWrapperMixins/__init__.py +79 -5
  217. scitex/plt/_subplots/_FigWrapper.py +6 -6
  218. scitex/plt/_subplots/_SubplotsWrapper.py +28 -18
  219. scitex/plt/_subplots/_export_as_csv.py +35 -5
  220. scitex/plt/_subplots/_export_as_csv_formatters/__init__.py +8 -0
  221. scitex/plt/_subplots/_export_as_csv_formatters/_format_annotate.py +10 -21
  222. scitex/plt/_subplots/_export_as_csv_formatters/_format_eventplot.py +18 -7
  223. scitex/plt/_subplots/_export_as_csv_formatters/_format_imshow2d.py +28 -12
  224. scitex/plt/_subplots/_export_as_csv_formatters/_format_matshow.py +10 -4
  225. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot_imshow.py +13 -1
  226. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot_kde.py +12 -2
  227. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot_scatter.py +10 -3
  228. scitex/plt/_subplots/_export_as_csv_formatters/_format_quiver.py +10 -4
  229. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_jointplot.py +18 -3
  230. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_lineplot.py +44 -36
  231. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_pairplot.py +14 -2
  232. scitex/plt/_subplots/_export_as_csv_formatters/_format_streamplot.py +11 -5
  233. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_bar.py +84 -0
  234. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_barh.py +85 -0
  235. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_conf_mat.py +14 -3
  236. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_contour.py +54 -0
  237. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_ecdf.py +14 -2
  238. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_errorbar.py +120 -0
  239. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_heatmap.py +16 -6
  240. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_image.py +29 -19
  241. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_imshow.py +63 -0
  242. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_joyplot.py +22 -5
  243. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_mean_ci.py +18 -14
  244. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_mean_std.py +18 -14
  245. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_median_iqr.py +18 -14
  246. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_raster.py +10 -2
  247. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_scatter.py +51 -0
  248. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_scatter_hist.py +18 -9
  249. scitex/plt/ax/_plot/_stx_ecdf.py +4 -2
  250. scitex/plt/gallery/_generate.py +421 -14
  251. scitex/plt/io/__init__.py +53 -0
  252. scitex/plt/io/_bundle.py +490 -0
  253. scitex/plt/io/_layered_bundle.py +1343 -0
  254. scitex/plt/styles/SCITEX_STYLE.yaml +26 -0
  255. scitex/plt/styles/__init__.py +14 -0
  256. scitex/plt/styles/presets.py +78 -0
  257. scitex/plt/utils/__init__.py +13 -1
  258. scitex/plt/utils/_collect_figure_metadata.py +10 -14
  259. scitex/plt/utils/_configure_mpl.py +6 -18
  260. scitex/plt/utils/_crop.py +32 -14
  261. scitex/plt/utils/_csv_column_naming.py +54 -0
  262. scitex/plt/utils/_figure_mm.py +116 -1
  263. scitex/plt/utils/_hitmap.py +1643 -0
  264. scitex/plt/utils/metadata/__init__.py +25 -0
  265. scitex/plt/utils/metadata/_core.py +9 -10
  266. scitex/plt/utils/metadata/_dimensions.py +6 -3
  267. scitex/plt/utils/metadata/_editable_export.py +405 -0
  268. scitex/plt/utils/metadata/_geometry_extraction.py +570 -0
  269. scitex/schema/__init__.py +109 -16
  270. scitex/schema/_canvas.py +1 -1
  271. scitex/schema/_plot.py +1015 -0
  272. scitex/schema/_stats.py +2 -2
  273. scitex/stats/__init__.py +117 -0
  274. scitex/stats/io/__init__.py +29 -0
  275. scitex/stats/io/_bundle.py +156 -0
  276. scitex/tex/__init__.py +4 -0
  277. scitex/tex/_export.py +890 -0
  278. {scitex-2.7.0.dist-info → scitex-2.8.1.dist-info}/METADATA +11 -1
  279. {scitex-2.7.0.dist-info → scitex-2.8.1.dist-info}/RECORD +294 -170
  280. scitex/io/memo.md +0 -2827
  281. scitex/plt/REQUESTS.md +0 -191
  282. scitex/plt/_subplots/TODO.md +0 -53
  283. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin.py +0 -559
  284. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin.py +0 -1609
  285. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin.py +0 -447
  286. scitex/plt/templates/research-master/scitex/vis/gallery/area/fill_between.json +0 -110
  287. scitex/plt/templates/research-master/scitex/vis/gallery/area/fill_betweenx.json +0 -88
  288. scitex/plt/templates/research-master/scitex/vis/gallery/area/stx_fill_between.json +0 -103
  289. scitex/plt/templates/research-master/scitex/vis/gallery/area/stx_fillv.json +0 -106
  290. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/bar.json +0 -92
  291. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/barh.json +0 -92
  292. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/boxplot.json +0 -92
  293. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_bar.json +0 -84
  294. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_barh.json +0 -84
  295. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_box.json +0 -83
  296. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_boxplot.json +0 -93
  297. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_violin.json +0 -91
  298. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_violinplot.json +0 -91
  299. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/violinplot.json +0 -91
  300. scitex/plt/templates/research-master/scitex/vis/gallery/contour/contour.json +0 -97
  301. scitex/plt/templates/research-master/scitex/vis/gallery/contour/contourf.json +0 -98
  302. scitex/plt/templates/research-master/scitex/vis/gallery/contour/stx_contour.json +0 -84
  303. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/hist.json +0 -101
  304. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/hist2d.json +0 -96
  305. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/stx_ecdf.json +0 -95
  306. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/stx_joyplot.json +0 -95
  307. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/stx_kde.json +0 -93
  308. scitex/plt/templates/research-master/scitex/vis/gallery/grid/imshow.json +0 -95
  309. scitex/plt/templates/research-master/scitex/vis/gallery/grid/matshow.json +0 -95
  310. scitex/plt/templates/research-master/scitex/vis/gallery/grid/stx_conf_mat.json +0 -83
  311. scitex/plt/templates/research-master/scitex/vis/gallery/grid/stx_heatmap.json +0 -92
  312. scitex/plt/templates/research-master/scitex/vis/gallery/grid/stx_image.json +0 -121
  313. scitex/plt/templates/research-master/scitex/vis/gallery/grid/stx_imshow.json +0 -84
  314. scitex/plt/templates/research-master/scitex/vis/gallery/line/plot.json +0 -110
  315. scitex/plt/templates/research-master/scitex/vis/gallery/line/step.json +0 -92
  316. scitex/plt/templates/research-master/scitex/vis/gallery/line/stx_line.json +0 -95
  317. scitex/plt/templates/research-master/scitex/vis/gallery/line/stx_shaded_line.json +0 -96
  318. scitex/plt/templates/research-master/scitex/vis/gallery/scatter/hexbin.json +0 -95
  319. scitex/plt/templates/research-master/scitex/vis/gallery/scatter/scatter.json +0 -95
  320. scitex/plt/templates/research-master/scitex/vis/gallery/scatter/stem.json +0 -92
  321. scitex/plt/templates/research-master/scitex/vis/gallery/scatter/stx_scatter.json +0 -84
  322. scitex/plt/templates/research-master/scitex/vis/gallery/special/pie.json +0 -94
  323. scitex/plt/templates/research-master/scitex/vis/gallery/special/stx_raster.json +0 -109
  324. scitex/plt/templates/research-master/scitex/vis/gallery/special/stx_rectangle.json +0 -108
  325. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/errorbar.json +0 -93
  326. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/stx_errorbar.json +0 -84
  327. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/stx_mean_ci.json +0 -96
  328. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/stx_mean_std.json +0 -96
  329. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/stx_median_iqr.json +0 -96
  330. scitex/plt/templates/research-master/scitex/vis/gallery/vector/quiver.json +0 -99
  331. scitex/plt/templates/research-master/scitex/vis/gallery/vector/streamplot.json +0 -100
  332. scitex/vis/__init__.py +0 -177
  333. scitex/vis/editor/_edit.py +0 -390
  334. scitex/vis/editor/flask_editor/_bbox.py +0 -529
  335. scitex/vis/editor/flask_editor/_core.py +0 -168
  336. scitex/vis/editor/flask_editor/_renderer.py +0 -393
  337. scitex/vis/editor/flask_editor/templates/__init__.py +0 -33
  338. scitex/vis/editor/flask_editor/templates/_html.py +0 -513
  339. scitex/vis/editor/flask_editor/templates/_scripts.py +0 -1261
  340. scitex/vis/editor/flask_editor/templates/_styles.py +0 -739
  341. /scitex/{vis → fig}/README.md +0 -0
  342. /scitex/{vis → fig}/backend/__init__.py +0 -0
  343. /scitex/{vis → fig}/backend/_export.py +0 -0
  344. /scitex/{vis → fig}/backend/_render.py +0 -0
  345. /scitex/{vis → fig}/docs/CANVAS_ARCHITECTURE.md +0 -0
  346. /scitex/{vis → fig}/editor/_flask_editor.py +0 -0
  347. /scitex/{vis → fig}/editor/flask_editor/__init__.py +0 -0
  348. /scitex/{vis → fig}/editor/flask_editor/_utils.py +0 -0
  349. /scitex/{vis → fig}/io/_directory.py +0 -0
  350. /scitex/{vis → fig}/model/_plot_types.py +0 -0
  351. /scitex/{vis → fig}/utils/_defaults.py +0 -0
  352. /scitex/{vis → fig}/utils/_validate.py +0 -0
  353. {scitex-2.7.0.dist-info → scitex-2.8.1.dist-info}/WHEEL +0 -0
  354. {scitex-2.7.0.dist-info → scitex-2.8.1.dist-info}/entry_points.txt +0 -0
  355. {scitex-2.7.0.dist-info → scitex-2.8.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1343 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # Timestamp: "2025-12-14 (ywatanabe)"
4
+ # File: /home/ywatanabe/proj/scitex-code/src/scitex/plt/io/_layered_bundle.py
5
+
6
+ """
7
+ Layered .pltz Bundle I/O - New schema with spec/style/geometry separation.
8
+
9
+ Bundle structure:
10
+ plot.pltz.d/
11
+ spec.json # Semantic: WHAT to plot (canonical)
12
+ style.json # Appearance: HOW it looks (canonical)
13
+ data.csv # Raw data (immutable)
14
+ exports/
15
+ overview.png # Preview image
16
+ overview.svg # Vector preview
17
+ hitmap.png # Hit testing image
18
+ cache/
19
+ geometry_px.json # Derived: WHERE in pixels (regenerable)
20
+ render_manifest.json # Render metadata (dpi, hashes)
21
+
22
+ Design Principles:
23
+ - spec.json + style.json = source of truth (edit these)
24
+ - cache/* = derived, can be deleted and regenerated
25
+ - Canonical units: ratio (0-1) for axes bbox, mm for panel size
26
+ """
27
+
28
+ import json
29
+ import hashlib
30
+ from pathlib import Path
31
+ from typing import Any, Dict, List, Optional, Tuple
32
+ from dataclasses import asdict
33
+
34
+ from scitex import logging
35
+ from scitex.plt.styles import get_default_dpi, get_preview_dpi
36
+ from scitex.schema import (
37
+ # Spec classes
38
+ PltzSpec, PltzTraceSpec, PltzAxesItem, PltzAxesLimits, PltzAxesLabels,
39
+ PltzDataSource, BboxRatio, BboxPx,
40
+ # Style classes
41
+ PltzStyle, PltzTheme, PltzFont, PltzSize, PltzTraceStyle, PltzLegendSpec,
42
+ # Geometry classes
43
+ PltzGeometry, PltzRenderedAxes, PltzRenderedArtist, PltzRenderManifest,
44
+ # Version constants
45
+ PLOT_SPEC_VERSION, PLOT_STYLE_VERSION, PLOT_GEOMETRY_VERSION,
46
+ )
47
+
48
+ logger = logging.getLogger()
49
+
50
+ __all__ = [
51
+ "save_layered_pltz_bundle",
52
+ "load_layered_pltz_bundle",
53
+ "merge_layered_bundle",
54
+ "is_layered_bundle",
55
+ ]
56
+
57
+
58
+ def is_layered_bundle(bundle_dir: Path) -> bool:
59
+ """Check if a bundle uses the new layered format."""
60
+ return (bundle_dir / "spec.json").exists()
61
+
62
+
63
+ def save_layered_pltz_bundle(
64
+ fig,
65
+ bundle_dir: Path,
66
+ basename: str = "plot",
67
+ dpi: Optional[int] = None,
68
+ csv_df=None,
69
+ ) -> None:
70
+ """
71
+ Save matplotlib figure as layered .pltz bundle.
72
+
73
+ Parameters
74
+ ----------
75
+ fig : matplotlib.figure.Figure
76
+ The figure to save.
77
+ bundle_dir : Path
78
+ Output directory (e.g., plot.pltz.d).
79
+ basename : str
80
+ Base filename for exports.
81
+ dpi : int, optional
82
+ DPI for raster exports. If None, uses get_default_dpi() from config.
83
+ csv_df : DataFrame, optional
84
+ Data to embed as CSV.
85
+ """
86
+ # Resolve DPI from config if not specified
87
+ if dpi is None:
88
+ dpi = get_default_dpi()
89
+ import numpy as np
90
+ import tempfile
91
+ import warnings
92
+ from PIL import Image as PILImage
93
+
94
+ bundle_dir = Path(bundle_dir)
95
+ bundle_dir.mkdir(parents=True, exist_ok=True)
96
+
97
+ # Create subdirectories
98
+ exports_dir = bundle_dir / "exports"
99
+ cache_dir = bundle_dir / "cache"
100
+ exports_dir.mkdir(exist_ok=True)
101
+ cache_dir.mkdir(exist_ok=True)
102
+
103
+ # Extract figure dimensions
104
+ fig_width_inch, fig_height_inch = fig.get_size_inches()
105
+
106
+ # === Build PltzSpec (semantic) ===
107
+ axes_items = []
108
+ traces = []
109
+ extracted_data = {}
110
+
111
+ for ax_idx, ax in enumerate(fig.axes):
112
+ bbox = ax.get_position()
113
+ ax_id = f"ax{ax_idx}"
114
+
115
+ # Create axes item
116
+ ax_item = PltzAxesItem(
117
+ id=ax_id,
118
+ bbox=BboxRatio(
119
+ x0=round(bbox.x0, 4),
120
+ y0=round(bbox.y0, 4),
121
+ width=round(bbox.width, 4),
122
+ height=round(bbox.height, 4),
123
+ space="panel",
124
+ ),
125
+ limits=PltzAxesLimits(
126
+ x=list(ax.get_xlim()),
127
+ y=list(ax.get_ylim()),
128
+ ),
129
+ labels=PltzAxesLabels(
130
+ xlabel=ax.get_xlabel() or None,
131
+ ylabel=ax.get_ylabel() or None,
132
+ title=ax.get_title() or None,
133
+ ),
134
+ )
135
+ axes_items.append(ax_item)
136
+
137
+ # Extract traces from lines
138
+ for line_idx, line in enumerate(ax.get_lines()):
139
+ label = line.get_label()
140
+ if label is None or label.startswith('_'):
141
+ label = f'series_{line_idx}'
142
+
143
+ trace_id = f"{ax_id}-line-{line_idx}"
144
+ xdata, ydata = line.get_data()
145
+
146
+ if len(xdata) > 0:
147
+ x_col = f"{ax_id}_trace-{trace_id}_x"
148
+ y_col = f"{ax_id}_trace-{trace_id}_y"
149
+ extracted_data[x_col] = np.array(xdata)
150
+ extracted_data[y_col] = np.array(ydata)
151
+
152
+ trace = PltzTraceSpec(
153
+ id=trace_id,
154
+ type="line",
155
+ axes_index=ax_idx,
156
+ x_col=x_col,
157
+ y_col=y_col,
158
+ label=label,
159
+ )
160
+ traces.append(trace)
161
+
162
+ # Handle CSV data - prefer extracted data (captures all matplotlib artists)
163
+ columns = []
164
+ csv_hash = None
165
+ if extracted_data:
166
+ # Use extracted data from matplotlib artists (captures axhline, etc.)
167
+ import pandas as pd
168
+ max_len = max(len(v) for v in extracted_data.values())
169
+ padded = {}
170
+ for k, v in extracted_data.items():
171
+ # Convert to float for NaN padding compatibility
172
+ v_float = np.array(v, dtype=float)
173
+ if len(v_float) < max_len:
174
+ padded[k] = np.pad(v_float, (0, max_len - len(v_float)), constant_values=np.nan)
175
+ else:
176
+ padded[k] = v_float
177
+ csv_df = pd.DataFrame(padded)
178
+ columns = list(csv_df.columns)
179
+ csv_str = csv_df.to_csv(index=False)
180
+ csv_hash = f"sha256:{hashlib.sha256(csv_str.encode()).hexdigest()[:16]}"
181
+ elif csv_df is not None:
182
+ # Fallback to provided CSV if no extracted data
183
+ columns = list(csv_df.columns)
184
+ csv_str = csv_df.to_csv(index=False)
185
+ csv_hash = f"sha256:{hashlib.sha256(csv_str.encode()).hexdigest()[:16]}"
186
+
187
+ # Create spec
188
+ spec = PltzSpec(
189
+ plot_id=basename,
190
+ data=PltzDataSource(
191
+ csv=f"{basename}.csv",
192
+ format="wide",
193
+ hash=csv_hash,
194
+ ),
195
+ axes=axes_items,
196
+ traces=traces,
197
+ )
198
+
199
+ # === Build PltzStyle (appearance) ===
200
+ # Detect theme from figure
201
+ theme_mode = "light"
202
+ if hasattr(fig, '_scitex_theme'):
203
+ theme_mode = fig._scitex_theme
204
+
205
+ trace_styles = []
206
+ for ax_idx, ax in enumerate(fig.axes):
207
+ for line_idx, line in enumerate(ax.get_lines()):
208
+ label = line.get_label()
209
+ if label and not label.startswith('_'):
210
+ # Get line color
211
+ import matplotlib.colors as mcolors
212
+ color = line.get_color()
213
+ if isinstance(color, (list, tuple)):
214
+ color = mcolors.to_hex(color)
215
+
216
+ trace_id = f"ax{ax_idx}-line-{line_idx}"
217
+ trace_styles.append(PltzTraceStyle(
218
+ trace_id=trace_id,
219
+ color=color,
220
+ linewidth=line.get_linewidth(),
221
+ alpha=line.get_alpha(),
222
+ ))
223
+
224
+ # Extract legend configuration from first axes with legend
225
+ legend_spec = PltzLegendSpec(visible=True, location="best")
226
+ for ax in fig.axes:
227
+ legend = ax.get_legend()
228
+ if legend is not None:
229
+ # Extract legend location
230
+ # matplotlib legend._loc can be int or string
231
+ loc = legend._loc
232
+ loc_map = {
233
+ 0: "best", 1: "upper right", 2: "upper left", 3: "lower left",
234
+ 4: "lower right", 5: "right", 6: "center left", 7: "center right",
235
+ 8: "lower center", 9: "upper center", 10: "center",
236
+ }
237
+ if isinstance(loc, int):
238
+ location = loc_map.get(loc, "best")
239
+ else:
240
+ location = str(loc) if loc else "best"
241
+
242
+ # If location is "best", determine actual position from rendered bbox
243
+ if location == "best":
244
+ try:
245
+ # Get the actual rendered position
246
+ bbox = legend.get_window_extent(fig.canvas.get_renderer())
247
+ ax_bbox = ax.get_position()
248
+ fig_width, fig_height = fig.get_size_inches() * fig.dpi
249
+
250
+ # Calculate legend center relative to axes
251
+ legend_center_x = (bbox.x0 + bbox.x1) / 2
252
+ legend_center_y = (bbox.y0 + bbox.y1) / 2
253
+ ax_center_x = (ax_bbox.x0 + ax_bbox.x1) / 2 * fig_width
254
+ ax_center_y = (ax_bbox.y0 + ax_bbox.y1) / 2 * fig_height
255
+
256
+ # Determine quadrant
257
+ is_right = legend_center_x > ax_center_x
258
+ is_upper = legend_center_y > ax_center_y
259
+
260
+ if is_upper and is_right:
261
+ location = "upper right"
262
+ elif is_upper and not is_right:
263
+ location = "upper left"
264
+ elif not is_upper and is_right:
265
+ location = "lower right"
266
+ else:
267
+ location = "lower left"
268
+ except Exception:
269
+ pass # Keep "best" if we can't determine
270
+
271
+ # Extract other legend properties
272
+ legend_spec = PltzLegendSpec(
273
+ visible=legend.get_visible(),
274
+ location=location,
275
+ frameon=legend.get_frame_on(),
276
+ fontsize=legend._fontsize if hasattr(legend, '_fontsize') else None,
277
+ ncols=legend._ncols if hasattr(legend, '_ncols') else 1,
278
+ title=legend.get_title().get_text() if legend.get_title() else None,
279
+ )
280
+ break # Use first legend found
281
+
282
+ style = PltzStyle(
283
+ theme=PltzTheme(
284
+ mode=theme_mode,
285
+ colors={
286
+ "background": "transparent",
287
+ "axes_bg": "white" if theme_mode == "light" else "transparent",
288
+ "text": "black" if theme_mode == "light" else "#e8e8e8",
289
+ "spine": "black" if theme_mode == "light" else "#e8e8e8",
290
+ "tick": "black" if theme_mode == "light" else "#e8e8e8",
291
+ },
292
+ ),
293
+ size=PltzSize(
294
+ width_mm=round(fig_width_inch * 25.4, 1),
295
+ height_mm=round(fig_height_inch * 25.4, 1),
296
+ ),
297
+ font=PltzFont(family="sans-serif", size_pt=8.0),
298
+ traces=trace_styles,
299
+ legend=legend_spec,
300
+ )
301
+
302
+ # === Save exports and track coordinate transformations ===
303
+ #
304
+ # Cropping Pipeline:
305
+ #
306
+ # ┌──────────────────────────────┐
307
+ # │ original_figure_size_px │ ← matplotlib figure canvas
308
+ # │ ┌────────────────────────┐ │ (where bbox_px are measured)
309
+ # │ │ tight_bbox │ │
310
+ # │ │ ┌──────────────────┐ │ │
311
+ # │ │ │ final_image_px │ │ │ ← exported PNG (what user sees)
312
+ # │ │ └──────────────────┘ │ │
313
+ # │ └────────────────────────┘ │
314
+ # └──────────────────────────────┘
315
+ #
316
+ # To convert: final_coord = original_coord - total_crop_offset
317
+ #
318
+ # IMPORTANT: Use fig.dpi for coordinate calculations, NOT export dpi
319
+ # extract_selectable_regions uses fig.dpi for all bbox calculations
320
+ fig_dpi = fig.dpi
321
+ display_fig_size_px = [
322
+ int(fig.get_figwidth() * fig_dpi),
323
+ int(fig.get_figheight() * fig_dpi),
324
+ ]
325
+
326
+ # Get matplotlib's tight bounding box (what bbox_inches='tight' crops to)
327
+ # Note: get_tightbbox returns values in INCHES, not pixels
328
+ fig.canvas.draw() # Ensure renderer is ready
329
+ renderer = fig.canvas.get_renderer()
330
+ tight_bbox_inches = fig.get_tightbbox(renderer)
331
+
332
+ # Convert from inches to display pixels (using fig.dpi, NOT export dpi)
333
+ tight_bbox_display_px = {
334
+ "x0": tight_bbox_inches.x0 * fig_dpi,
335
+ "y0": tight_bbox_inches.y0 * fig_dpi,
336
+ "x1": tight_bbox_inches.x1 * fig_dpi,
337
+ "y1": tight_bbox_inches.y1 * fig_dpi,
338
+ }
339
+
340
+ # Convert from matplotlib display coords (y=0 at bottom)
341
+ # to image coords (y=0 at top)
342
+ tight_bbox_in_image_coords = {
343
+ "left": tight_bbox_display_px["x0"],
344
+ "upper": display_fig_size_px[1] - tight_bbox_display_px["y1"], # Flip y
345
+ "right": tight_bbox_display_px["x1"],
346
+ "lower": display_fig_size_px[1] - tight_bbox_display_px["y0"],
347
+ }
348
+
349
+ # Scale factor: export_dpi / fig_dpi (to scale from display coords to export PNG coords)
350
+ dpi_scale = dpi / fig_dpi
351
+
352
+ with tempfile.TemporaryDirectory() as tmp_dir:
353
+ tmp_path = Path(tmp_dir)
354
+
355
+ with warnings.catch_warnings():
356
+ warnings.filterwarnings('ignore', message='.*tight_layout.*')
357
+
358
+ # Save PNG at full figure size (crop function will handle tight cropping)
359
+ # This ensures coordinate transformations are accurate
360
+ png_path = exports_dir / f"{basename}.png"
361
+ fig.savefig(png_path, dpi=dpi, format='png', transparent=True)
362
+
363
+ # Save SVG with tight bbox (separate concern, no coord issues)
364
+ svg_path = exports_dir / f"{basename}.svg"
365
+ fig.savefig(svg_path, bbox_inches='tight', format='svg')
366
+
367
+ # Generate hitmap
368
+ from scitex.plt.utils._hitmap import (
369
+ apply_hitmap_colors, restore_original_colors,
370
+ extract_path_data, extract_selectable_regions,
371
+ HITMAP_BACKGROUND_COLOR, HITMAP_AXES_COLOR
372
+ )
373
+
374
+ original_props, color_map, groups = apply_hitmap_colors(fig)
375
+
376
+ # Store and set hitmap colors for hitmap generation
377
+ saved_fig_facecolor = fig.patch.get_facecolor()
378
+ saved_ax_facecolors = []
379
+ for ax in fig.axes:
380
+ saved_ax_facecolors.append(ax.get_facecolor())
381
+ ax.set_facecolor(HITMAP_BACKGROUND_COLOR)
382
+ for spine in ax.spines.values():
383
+ spine.set_color(HITMAP_AXES_COLOR)
384
+
385
+ fig.patch.set_facecolor(HITMAP_BACKGROUND_COLOR)
386
+
387
+ # Save hitmap at full figure size (will crop with same box as main PNG)
388
+ hitmap_path = exports_dir / f"{basename}_hitmap.png"
389
+ fig.savefig(hitmap_path, dpi=dpi, format='png',
390
+ facecolor=HITMAP_BACKGROUND_COLOR)
391
+
392
+ # Restore colors
393
+ restore_original_colors(original_props)
394
+ fig.patch.set_facecolor(saved_fig_facecolor)
395
+ for i, ax in enumerate(fig.axes):
396
+ ax.set_facecolor(saved_ax_facecolors[i])
397
+
398
+ # Apply additional margin cropping (removes transparent edges)
399
+ margin_crop_box = None
400
+ try:
401
+ from scitex.plt.utils._crop import crop
402
+
403
+ _, margin_crop_box = crop(
404
+ str(png_path), output_path=str(png_path),
405
+ overwrite=True, margin=12, verbose=False, return_offset=True,
406
+ )
407
+
408
+ crop(str(hitmap_path), output_path=str(hitmap_path),
409
+ overwrite=True, crop_box=(
410
+ margin_crop_box['left'],
411
+ margin_crop_box['upper'],
412
+ margin_crop_box['right'],
413
+ margin_crop_box['lower'],
414
+ ), verbose=False)
415
+ except Exception as e:
416
+ logger.debug(f"Crop failed: {e}")
417
+
418
+ # === Coordinate transformation pipeline ===
419
+ #
420
+ # Strategy: Since we save at full figure size (no bbox_inches='tight'),
421
+ # the crop function's crop_box IS the total offset from original figure to final PNG.
422
+ #
423
+ # We extract coordinates at export DPI so they match the saved PNG directly.
424
+
425
+ # Temporarily set fig.dpi to export DPI for coordinate extraction
426
+ saved_fig_dpi = fig.dpi
427
+ fig.set_dpi(dpi)
428
+ fig.canvas.draw() # Redraw at new DPI
429
+
430
+ # crop_box is the total offset from full figure to final PNG
431
+ # (crop_box['left'], crop_box['upper']) = top-left corner of crop region
432
+ total_offset_left = margin_crop_box["left"] if margin_crop_box else 0
433
+ total_offset_upper = margin_crop_box["upper"] if margin_crop_box else 0
434
+
435
+ # === Build PltzGeometry (cache) ===
436
+ # Extract at export DPI so coords are in full figure space (matches saved PNG before crop)
437
+ path_data = extract_path_data(fig)
438
+ selectable_regions = extract_selectable_regions(fig)
439
+
440
+ # Restore original DPI
441
+ fig.set_dpi(saved_fig_dpi)
442
+
443
+ # Get final image size (what user sees)
444
+ with PILImage.open(png_path) as img:
445
+ final_image_size_px = list(img.size)
446
+
447
+ # Adjust coordinates: subtract total offset (both tight_bbox and margin_crop)
448
+ # No DPI scaling needed since we extracted at export DPI
449
+ selectable_regions = _adjust_coords_for_offset(
450
+ selectable_regions, total_offset_left, total_offset_upper
451
+ )
452
+ path_data = _adjust_path_data_for_offset(
453
+ path_data, total_offset_left, total_offset_upper
454
+ )
455
+
456
+ rendered_axes = []
457
+ for ax_idx, ax_data in enumerate(path_data.get("axes", [])):
458
+ bbox_data = ax_data.get("bbox_px", {})
459
+ rendered_axes.append(PltzRenderedAxes(
460
+ id=f"ax{ax_idx}",
461
+ xlim=ax_data.get("xlim", [0, 1]),
462
+ ylim=ax_data.get("ylim", [0, 1]),
463
+ bbox_px=BboxPx(
464
+ x0=bbox_data.get("x0", 0),
465
+ y0=bbox_data.get("y0", 0),
466
+ width=bbox_data.get("width", bbox_data.get("x1", 0) - bbox_data.get("x0", 0)),
467
+ height=bbox_data.get("height", bbox_data.get("y1", 0) - bbox_data.get("y0", 0)),
468
+ ),
469
+ ))
470
+
471
+ rendered_artists = []
472
+ for artist in path_data.get("artists", []):
473
+ bbox_data = artist.get("bbox_px", {})
474
+ rendered_artists.append(PltzRenderedArtist(
475
+ id=str(artist.get("id", "")),
476
+ type=artist.get("type", "unknown"),
477
+ axes_index=artist.get("axes_index", 0),
478
+ bbox_px=BboxPx(
479
+ x0=bbox_data.get("x0", 0),
480
+ y0=bbox_data.get("y0", 0),
481
+ width=bbox_data.get("width", bbox_data.get("x1", 0) - bbox_data.get("x0", 0)),
482
+ height=bbox_data.get("height", bbox_data.get("y1", 0) - bbox_data.get("y0", 0)),
483
+ ) if bbox_data else None,
484
+ path_px=artist.get("path_px"),
485
+ ))
486
+
487
+ geometry = PltzGeometry(
488
+ source_hash=csv_hash or "",
489
+ figure_px=final_image_size_px, # Final cropped image size
490
+ dpi=dpi, # Export DPI (stored for consumers)
491
+ axes=rendered_axes,
492
+ artists=rendered_artists,
493
+ hit_regions={
494
+ "strategy": "hybrid",
495
+ "hit_map": f"{basename}_hitmap.png",
496
+ "color_map": {str(k): v for k, v in color_map.items()},
497
+ "groups": groups,
498
+ # Store DPI info for consumers that need to retrieve from data
499
+ "fig_dpi": fig_dpi, # Original matplotlib fig.dpi
500
+ "export_dpi": dpi, # Export DPI used for PNG
501
+ "dpi_scale": dpi_scale, # export_dpi / fig_dpi
502
+ },
503
+ selectable_regions=selectable_regions,
504
+ # Note: crop_box is now None because all coordinates are already adjusted
505
+ # to final_image space (no further transformation needed by consumers)
506
+ crop_box=None,
507
+ )
508
+
509
+ # === Save all JSON files ===
510
+ # spec.json
511
+ spec_path = bundle_dir / "spec.json"
512
+ with open(spec_path, "w") as f:
513
+ json.dump({
514
+ "schema": {"name": "scitex.plt.spec", "version": PLOT_SPEC_VERSION},
515
+ **asdict(spec),
516
+ }, f, indent=2, default=str)
517
+
518
+ # style.json
519
+ style_path = bundle_dir / "style.json"
520
+ with open(style_path, "w") as f:
521
+ json.dump({
522
+ "schema": {"name": "scitex.plt.style", "version": PLOT_STYLE_VERSION},
523
+ **asdict(style),
524
+ }, f, indent=2, default=str)
525
+
526
+ # cache/geometry_px.json
527
+ geometry_path = cache_dir / "geometry_px.json"
528
+ with open(geometry_path, "w") as f:
529
+ json.dump({
530
+ "schema": {"name": "scitex.plt.geometry", "version": PLOT_GEOMETRY_VERSION},
531
+ "_comment": "CACHE - can be deleted and regenerated from spec + style",
532
+ **asdict(geometry),
533
+ }, f, indent=2, default=str)
534
+
535
+ # cache/render_manifest.json
536
+ spec_hash = hashlib.sha256(open(spec_path, "rb").read()).hexdigest()[:16]
537
+ style_hash = hashlib.sha256(open(style_path, "rb").read()).hexdigest()[:16]
538
+ manifest = PltzRenderManifest(
539
+ source_hash=f"{spec_hash}:{style_hash}",
540
+ panel_size_mm=[round(fig_width_inch * 25.4, 1), round(fig_height_inch * 25.4, 1)],
541
+ dpi=dpi,
542
+ render_px=final_image_size_px,
543
+ overview_png=f"exports/{basename}.png",
544
+ overview_svg=f"exports/{basename}.svg",
545
+ hitmap_png=f"exports/{basename}_hitmap.png",
546
+ )
547
+ manifest_path = cache_dir / "render_manifest.json"
548
+ with open(manifest_path, "w") as f:
549
+ json.dump({
550
+ "schema": {"name": "scitex.plt.render_manifest", "version": PLOT_GEOMETRY_VERSION},
551
+ **asdict(manifest),
552
+ }, f, indent=2, default=str)
553
+
554
+ # Save CSV
555
+ if csv_df is not None:
556
+ csv_path = bundle_dir / f"{basename}.csv"
557
+ csv_df.to_csv(csv_path, index=False)
558
+
559
+ # Generate overview showing main image and hitmap side by side
560
+ _generate_pltz_overview(exports_dir, basename)
561
+
562
+ # Generate dynamic README.md
563
+ _generate_pltz_readme(bundle_dir, basename, spec, style, geometry, manifest)
564
+
565
+ logger.debug(f"Saved layered pltz bundle: {bundle_dir}")
566
+
567
+
568
+ def _generate_pltz_overview(exports_dir: Path, basename: str) -> None:
569
+ """Generate comprehensive overview with plot, hitmap, overlay, bboxes, and JSON info.
570
+
571
+ Args:
572
+ exports_dir: Path to exports directory.
573
+ basename: Base filename for the bundle.
574
+ """
575
+ import matplotlib.pyplot as plt
576
+ import matplotlib.patches as patches
577
+ from PIL import Image
578
+ import warnings
579
+ import numpy as np
580
+
581
+ bundle_dir = exports_dir.parent
582
+ png_path = exports_dir / f"{basename}.png"
583
+ hitmap_path = exports_dir / f"{basename}_hitmap.png"
584
+
585
+ if not png_path.exists():
586
+ return
587
+
588
+ try:
589
+ main_img = Image.open(png_path)
590
+ img_width, img_height = main_img.size
591
+ has_hitmap = hitmap_path.exists()
592
+
593
+ # Load JSON files for displaying info
594
+ spec_data = {}
595
+ style_data = {}
596
+ geometry_data = {}
597
+ manifest_data = {}
598
+
599
+ spec_path = bundle_dir / "spec.json"
600
+ style_path = bundle_dir / "style.json"
601
+ geometry_path = bundle_dir / "cache" / "geometry_px.json"
602
+ manifest_path = bundle_dir / "cache" / "render_manifest.json"
603
+
604
+ if spec_path.exists():
605
+ with open(spec_path, "r") as f:
606
+ spec_data = json.load(f)
607
+ if style_path.exists():
608
+ with open(style_path, "r") as f:
609
+ style_data = json.load(f)
610
+ if geometry_path.exists():
611
+ with open(geometry_path, "r") as f:
612
+ geometry_data = json.load(f)
613
+ if manifest_path.exists():
614
+ with open(manifest_path, "r") as f:
615
+ manifest_data = json.load(f)
616
+
617
+ # Get DPI and panel size for mm scaler
618
+ dpi = manifest_data.get("dpi", get_default_dpi())
619
+ panel_size_mm = manifest_data.get("panel_size_mm", [80, 68])
620
+
621
+ # Create figure with 2 rows, 3 columns layout
622
+ # Row 1: Plot | Hitmap | Overlay
623
+ # Row 2: Bboxes | JSON Info | mm Scaler
624
+ fig = plt.figure(figsize=(18, 12), facecolor="white")
625
+ gs = fig.add_gridspec(2, 3, hspace=0.3, wspace=0.2)
626
+
627
+ # === Row 1: Images ===
628
+ # 1. Main Plot
629
+ ax_plot = fig.add_subplot(gs[0, 0])
630
+ ax_plot.set_title("Plot", fontweight="bold", fontsize=11)
631
+ ax_plot.imshow(main_img)
632
+ ax_plot.axis("off")
633
+
634
+ # 2. Hitmap with ID labels
635
+ ax_hitmap = fig.add_subplot(gs[0, 1])
636
+ ax_hitmap.set_title("Hit Regions", fontweight="bold", fontsize=11)
637
+ if has_hitmap:
638
+ hitmap_img = Image.open(hitmap_path)
639
+ ax_hitmap.imshow(hitmap_img)
640
+
641
+ # Add ID labels from hit_regions color_map
642
+ color_map = geometry_data.get("hit_regions", {}).get("color_map", {})
643
+ artists = geometry_data.get("artists", [])
644
+
645
+ # Note: bbox_px coordinates are already in final image space
646
+ for idx, artist in enumerate(artists):
647
+ bbox = artist.get("bbox_px", {})
648
+ if bbox:
649
+ # Get center of bbox for label placement
650
+ x0 = bbox.get("x0", 0)
651
+ y0 = bbox.get("y0", 0)
652
+ width = bbox.get("width", 0)
653
+ height = bbox.get("height", 0)
654
+ cx, cy = x0 + width / 2, y0 + height / 2
655
+
656
+ # Find label from color_map (color_map IDs are 1-indexed)
657
+ color_map_id = str(idx + 1)
658
+ label = f"artist_{idx}"
659
+ if color_map_id in color_map:
660
+ label = color_map[color_map_id].get("label", label)
661
+
662
+ ax_hitmap.text(cx, cy, label, fontsize=8, ha="center", va="center",
663
+ color="white", fontweight="bold",
664
+ bbox=dict(boxstyle="round,pad=0.2", facecolor="black", alpha=0.7))
665
+ else:
666
+ ax_hitmap.text(0.5, 0.5, "No hitmap", ha="center", va="center",
667
+ transform=ax_hitmap.transAxes)
668
+ ax_hitmap.axis("off")
669
+
670
+ # 3. Overlay (plot + hitmap with transparency)
671
+ ax_overlay = fig.add_subplot(gs[0, 2])
672
+ ax_overlay.set_title("Overlay (Plot + Hit)", fontweight="bold", fontsize=11)
673
+ ax_overlay.imshow(main_img)
674
+ if has_hitmap:
675
+ hitmap_img = Image.open(hitmap_path).convert("RGBA")
676
+ hitmap_array = np.array(hitmap_img)
677
+ # Create semi-transparent overlay
678
+ hitmap_array[:, :, 3] = (hitmap_array[:, :, 3] * 0.5).astype(np.uint8)
679
+ ax_overlay.imshow(hitmap_array, alpha=0.5)
680
+ ax_overlay.axis("off")
681
+
682
+ # === Row 2: Details ===
683
+ # 4. Bboxes visualization
684
+ ax_bboxes = fig.add_subplot(gs[1, 0])
685
+ ax_bboxes.set_title("Element Bboxes", fontweight="bold", fontsize=11)
686
+ ax_bboxes.imshow(main_img)
687
+
688
+ # Note: bbox_px coordinates are already in final image space
689
+ # (adjusted during save_layered_pltz_bundle), so no offset needed
690
+
691
+ # Draw bboxes from geometry
692
+ colors = ["red", "blue", "green", "orange", "purple", "cyan"]
693
+ selectable = geometry_data.get("selectable_regions", {})
694
+
695
+ for ax_idx, ax_region in enumerate(selectable.get("axes", [])):
696
+ color = colors[ax_idx % len(colors)]
697
+
698
+ # Title bbox
699
+ if "title" in ax_region:
700
+ bbox = ax_region["title"].get("bbox_px", [])
701
+ if len(bbox) == 4:
702
+ _draw_bbox(ax_bboxes, bbox, color, "title")
703
+
704
+ # xlabel bbox
705
+ if "xlabel" in ax_region:
706
+ bbox = ax_region["xlabel"].get("bbox_px", [])
707
+ if len(bbox) == 4:
708
+ _draw_bbox(ax_bboxes, bbox, color, "xlabel")
709
+
710
+ # ylabel bbox
711
+ if "ylabel" in ax_region:
712
+ bbox = ax_region["ylabel"].get("bbox_px", [])
713
+ if len(bbox) == 4:
714
+ _draw_bbox(ax_bboxes, bbox, color, "ylabel")
715
+
716
+ # xaxis spine
717
+ if "xaxis" in ax_region and "spine" in ax_region["xaxis"]:
718
+ bbox = ax_region["xaxis"]["spine"].get("bbox_px", [])
719
+ if len(bbox) == 4:
720
+ _draw_bbox(ax_bboxes, bbox, "gray", "xaxis", lw=1)
721
+
722
+ # yaxis spine
723
+ if "yaxis" in ax_region and "spine" in ax_region["yaxis"]:
724
+ bbox = ax_region["yaxis"]["spine"].get("bbox_px", [])
725
+ if len(bbox) == 4:
726
+ _draw_bbox(ax_bboxes, bbox, "gray", "yaxis", lw=1)
727
+
728
+ # legend bbox
729
+ if "legend" in ax_region:
730
+ bbox = ax_region["legend"].get("bbox_px", [])
731
+ if len(bbox) == 4:
732
+ _draw_bbox(ax_bboxes, bbox, "magenta", "legend")
733
+
734
+ ax_bboxes.axis("off")
735
+
736
+ # 5. JSON Info
737
+ ax_json = fig.add_subplot(gs[1, 1])
738
+ ax_json.set_title("Bundle Info (depth=2)", fontweight="bold", fontsize=11)
739
+ ax_json.axis("off")
740
+
741
+ # Format JSON summary with limited depth
742
+ json_text = _format_json_summary(
743
+ {"spec": spec_data, "style": style_data},
744
+ max_depth=2
745
+ )
746
+ ax_json.text(0.02, 0.98, json_text, transform=ax_json.transAxes,
747
+ fontsize=7, fontfamily="monospace", verticalalignment="top",
748
+ bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.5))
749
+
750
+ # 6. mm Scaler
751
+ ax_scale = fig.add_subplot(gs[1, 2])
752
+ ax_scale.set_title("Size & Scale (mm)", fontweight="bold", fontsize=11)
753
+
754
+ # Show the image with mm scale bars
755
+ ax_scale.imshow(main_img, extent=[0, panel_size_mm[0], panel_size_mm[1], 0])
756
+
757
+ # Add grid lines every 10mm
758
+ for x in range(0, int(panel_size_mm[0]) + 1, 10):
759
+ ax_scale.axvline(x, color='gray', linewidth=0.5, alpha=0.5)
760
+ if x > 0:
761
+ ax_scale.text(x, -1, f"{x}", ha="center", fontsize=7)
762
+ for y in range(0, int(panel_size_mm[1]) + 1, 10):
763
+ ax_scale.axhline(y, color='gray', linewidth=0.5, alpha=0.5)
764
+ if y > 0:
765
+ ax_scale.text(-1, y, f"{y}", ha="right", va="center", fontsize=7)
766
+
767
+ ax_scale.set_xlabel("mm", fontsize=9)
768
+ ax_scale.set_ylabel("mm", fontsize=9)
769
+ ax_scale.set_xlim(-3, panel_size_mm[0] + 1)
770
+ ax_scale.set_ylim(panel_size_mm[1] + 1, -3)
771
+
772
+ # Add size text
773
+ size_text = f"Panel: {panel_size_mm[0]:.1f} × {panel_size_mm[1]:.1f} mm\nDPI: {dpi}\nPixels: {img_width} × {img_height}"
774
+ ax_scale.text(panel_size_mm[0] * 0.95, panel_size_mm[1] * 0.95, size_text,
775
+ ha="right", va="bottom", fontsize=8,
776
+ bbox=dict(boxstyle="round", facecolor="white", alpha=0.8))
777
+
778
+ fig.suptitle(f"Overview: {basename}", fontsize=14, fontweight="bold", y=0.98)
779
+
780
+ overview_path = exports_dir / f"{basename}_overview.png"
781
+ with warnings.catch_warnings():
782
+ warnings.filterwarnings("ignore", message=".*tight_layout.*")
783
+ fig.savefig(overview_path, dpi=get_preview_dpi(), bbox_inches="tight", facecolor="white")
784
+ plt.close(fig)
785
+
786
+ except Exception as e:
787
+ logger.debug(f"Could not generate pltz overview: {e}")
788
+ import traceback
789
+ logger.debug(traceback.format_exc())
790
+
791
+
792
+ def _generate_pltz_readme(
793
+ bundle_dir: Path,
794
+ basename: str,
795
+ spec: "PltzSpec",
796
+ style: "PltzStyle",
797
+ geometry: "PltzGeometry",
798
+ manifest: "PltzRenderManifest",
799
+ ) -> None:
800
+ """Generate a dynamic README.md describing the bundle.
801
+
802
+ Parameters
803
+ ----------
804
+ bundle_dir : Path
805
+ Path to the bundle directory.
806
+ basename : str
807
+ Base filename for the bundle.
808
+ spec : PltzSpec
809
+ The plot specification.
810
+ style : PltzStyle
811
+ The plot style.
812
+ geometry : PltzGeometry
813
+ The rendered geometry.
814
+ manifest : PltzRenderManifest
815
+ The render manifest.
816
+ """
817
+ from datetime import datetime
818
+
819
+ # Count elements
820
+ n_axes = len(spec.axes) if spec.axes else 0
821
+ n_traces = len(spec.traces) if spec.traces else 0
822
+
823
+ # Get size info
824
+ width_mm = style.size.width_mm if style.size else 0
825
+ height_mm = style.size.height_mm if style.size else 0
826
+ dpi = manifest.dpi
827
+ render_px = manifest.render_px
828
+
829
+ readme_content = f"""# {basename}.pltz.d
830
+
831
+ > SciTeX Layered Plot Bundle - Auto-generated README
832
+
833
+ ## Overview
834
+
835
+ ![Plot Overview](exports/{basename}_overview.png)
836
+
837
+ ## Bundle Structure
838
+
839
+ ```
840
+ {basename}.pltz.d/
841
+ ├── spec.json # WHAT to plot (semantic, editable)
842
+ ├── style.json # HOW it looks (appearance, editable)
843
+ ├── {basename}.csv # Raw data (immutable)
844
+ ├── exports/
845
+ │ ├── {basename}.png # Main plot image
846
+ │ ├── {basename}.svg # Vector version
847
+ │ ├── {basename}_hitmap.png # Hit detection image
848
+ │ └── {basename}_overview.png # Visual summary
849
+ ├── cache/
850
+ │ ├── geometry_px.json # Pixel coordinates (regenerable)
851
+ │ └── render_manifest.json # Render metadata
852
+ └── README.md # This file
853
+ ```
854
+
855
+ ## Plot Information
856
+
857
+ | Property | Value |
858
+ |----------|-------|
859
+ | Plot ID | `{spec.plot_id}` |
860
+ | Axes | {n_axes} |
861
+ | Traces | {n_traces} |
862
+ | Size | {width_mm:.1f} × {height_mm:.1f} mm |
863
+ | DPI | {dpi} |
864
+ | Pixels | {render_px[0]} × {render_px[1]} |
865
+ | Theme | {style.theme.mode if style.theme else 'light'} |
866
+
867
+ ## Coordinate System
868
+
869
+ The bundle uses a layered coordinate system:
870
+
871
+ 1. **spec.json + style.json** = Source of truth (edit these)
872
+ 2. **cache/** = Derived data (can be deleted and regenerated)
873
+
874
+ ### Coordinate Transformation Pipeline
875
+
876
+ ```
877
+ Original Figure (at export DPI)
878
+
879
+ ▼ crop_box offset
880
+ ┌─────────────────┐
881
+ │ Final PNG │ ← bbox_px coordinates are in this space
882
+ │ ({render_px[0]} × {render_px[1]}) │
883
+ └─────────────────┘
884
+ ```
885
+
886
+ **Formula**: `final_coords = original_coords - crop_offset`
887
+
888
+ ## Usage
889
+
890
+ ### Python
891
+
892
+ ```python
893
+ import scitex as stx
894
+
895
+ # Load the bundle
896
+ bundle = stx.plt.io.load_layered_pltz_bundle("{bundle_dir}")
897
+
898
+ # Access components
899
+ spec = bundle["spec"] # What to plot
900
+ style = bundle["style"] # How it looks
901
+ geometry = bundle["geometry"] # Where in pixels
902
+ ```
903
+
904
+ ### Editing
905
+
906
+ Edit `spec.json` to change:
907
+ - Axis labels, titles, limits
908
+ - Trace data columns
909
+ - Data source
910
+
911
+ Edit `style.json` to change:
912
+ - Colors, line widths
913
+ - Font sizes
914
+ - Theme (light/dark)
915
+
916
+ After editing, regenerate cache with:
917
+ ```python
918
+ stx.plt.io.regenerate_cache("{bundle_dir}")
919
+ ```
920
+
921
+ ---
922
+
923
+ *Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*
924
+ *Schema: scitex.plt v{PLOT_SPEC_VERSION}*
925
+ """
926
+
927
+ readme_path = bundle_dir / "README.md"
928
+ with open(readme_path, "w") as f:
929
+ f.write(readme_content)
930
+
931
+
932
+ def _adjust_coords_for_offset(
933
+ selectable_regions: Dict[str, Any],
934
+ offset_left: float,
935
+ offset_upper: float,
936
+ ) -> Dict[str, Any]:
937
+ """Adjust bbox_px coordinates by subtracting offset.
938
+
939
+ Used when coordinates are already in PNG space (extracted at export DPI).
940
+
941
+ Parameters
942
+ ----------
943
+ selectable_regions : dict
944
+ The selectable_regions dict (already in PNG coords).
945
+ offset_left : float
946
+ Total offset from left edge to subtract.
947
+ offset_upper : float
948
+ Total offset from top edge to subtract.
949
+
950
+ Returns
951
+ -------
952
+ dict
953
+ selectable_regions with adjusted coordinates.
954
+ """
955
+ import copy
956
+ result = copy.deepcopy(selectable_regions)
957
+
958
+ def adjust_bbox(bbox: List[float]) -> List[float]:
959
+ """Subtract offset from [x0, y0, x1, y1] bbox."""
960
+ return [
961
+ bbox[0] - offset_left,
962
+ bbox[1] - offset_upper,
963
+ bbox[2] - offset_left,
964
+ bbox[3] - offset_upper,
965
+ ]
966
+
967
+ for ax_region in result.get("axes", []):
968
+ # Adjust title, xlabel, ylabel
969
+ for key in ["title", "xlabel", "ylabel"]:
970
+ if key in ax_region and "bbox_px" in ax_region[key]:
971
+ ax_region[key]["bbox_px"] = adjust_bbox(ax_region[key]["bbox_px"])
972
+
973
+ # Adjust xaxis elements
974
+ if "xaxis" in ax_region:
975
+ xaxis = ax_region["xaxis"]
976
+ if xaxis.get("spine") and "bbox_px" in xaxis["spine"]:
977
+ xaxis["spine"]["bbox_px"] = adjust_bbox(xaxis["spine"]["bbox_px"])
978
+ for tick in xaxis.get("ticks", []):
979
+ if "bbox_px" in tick:
980
+ tick["bbox_px"] = adjust_bbox(tick["bbox_px"])
981
+ for label in xaxis.get("ticklabels", []):
982
+ if "bbox_px" in label:
983
+ label["bbox_px"] = adjust_bbox(label["bbox_px"])
984
+
985
+ # Adjust yaxis elements
986
+ if "yaxis" in ax_region:
987
+ yaxis = ax_region["yaxis"]
988
+ if yaxis.get("spine") and "bbox_px" in yaxis["spine"]:
989
+ yaxis["spine"]["bbox_px"] = adjust_bbox(yaxis["spine"]["bbox_px"])
990
+ for tick in yaxis.get("ticks", []):
991
+ if "bbox_px" in tick:
992
+ tick["bbox_px"] = adjust_bbox(tick["bbox_px"])
993
+ for label in yaxis.get("ticklabels", []):
994
+ if "bbox_px" in label:
995
+ label["bbox_px"] = adjust_bbox(label["bbox_px"])
996
+
997
+ # Adjust legend
998
+ if "legend" in ax_region:
999
+ legend = ax_region["legend"]
1000
+ if "bbox_px" in legend:
1001
+ legend["bbox_px"] = adjust_bbox(legend["bbox_px"])
1002
+ for entry in legend.get("entries", []):
1003
+ if "bbox_px" in entry:
1004
+ entry["bbox_px"] = adjust_bbox(entry["bbox_px"])
1005
+
1006
+ return result
1007
+
1008
+
1009
+ def _adjust_path_data_for_offset(
1010
+ path_data: Dict[str, Any],
1011
+ offset_left: float,
1012
+ offset_upper: float,
1013
+ ) -> Dict[str, Any]:
1014
+ """Adjust path_data coordinates by subtracting offset.
1015
+
1016
+ Used when coordinates are already in PNG space (extracted at export DPI).
1017
+
1018
+ Parameters
1019
+ ----------
1020
+ path_data : dict
1021
+ The path_data dict (already in PNG coords).
1022
+ offset_left : float
1023
+ Total offset from left edge to subtract.
1024
+ offset_upper : float
1025
+ Total offset from top edge to subtract.
1026
+
1027
+ Returns
1028
+ -------
1029
+ dict
1030
+ path_data with adjusted coordinates.
1031
+ """
1032
+ import copy
1033
+ result = copy.deepcopy(path_data)
1034
+
1035
+ # Adjust axes bbox_px
1036
+ for ax in result.get("axes", []):
1037
+ if "bbox_px" in ax:
1038
+ bbox = ax["bbox_px"]
1039
+ if isinstance(bbox, dict):
1040
+ bbox["x0"] = bbox.get("x0", 0) - offset_left
1041
+ bbox["y0"] = bbox.get("y0", 0) - offset_upper
1042
+ if "x1" in bbox:
1043
+ bbox["x1"] = bbox["x1"] - offset_left
1044
+ if "y1" in bbox:
1045
+ bbox["y1"] = bbox["y1"] - offset_upper
1046
+
1047
+ # Adjust artists
1048
+ for artist in result.get("artists", []):
1049
+ if "bbox_px" in artist and artist["bbox_px"]:
1050
+ bbox = artist["bbox_px"]
1051
+ if isinstance(bbox, dict):
1052
+ bbox["x0"] = bbox.get("x0", 0) - offset_left
1053
+ bbox["y0"] = bbox.get("y0", 0) - offset_upper
1054
+ if "x1" in bbox:
1055
+ bbox["x1"] = bbox["x1"] - offset_left
1056
+ if "y1" in bbox:
1057
+ bbox["y1"] = bbox["y1"] - offset_upper
1058
+
1059
+ # Adjust path_px points
1060
+ if "path_px" in artist and artist["path_px"]:
1061
+ artist["path_px"] = [
1062
+ [pt[0] - offset_left, pt[1] - offset_upper]
1063
+ for pt in artist["path_px"]
1064
+ if len(pt) >= 2
1065
+ ]
1066
+
1067
+ return result
1068
+
1069
+
1070
+ def _adjust_path_data_for_crop(
1071
+ path_data: Dict[str, Any],
1072
+ offset_left: float,
1073
+ offset_upper: float,
1074
+ ) -> Dict[str, Any]:
1075
+ """Adjust path_data coordinates by subtracting crop offset.
1076
+
1077
+ Parameters
1078
+ ----------
1079
+ path_data : dict
1080
+ The path_data dict from extract_path_data.
1081
+ offset_left : float
1082
+ Total offset from left edge.
1083
+ offset_upper : float
1084
+ Total offset from top edge.
1085
+
1086
+ Returns
1087
+ -------
1088
+ dict
1089
+ path_data with adjusted coordinates.
1090
+ """
1091
+ import copy
1092
+ result = copy.deepcopy(path_data)
1093
+
1094
+ # Adjust axes bbox_px
1095
+ for ax in result.get("axes", []):
1096
+ if "bbox_px" in ax:
1097
+ bbox = ax["bbox_px"]
1098
+ if isinstance(bbox, dict):
1099
+ bbox["x0"] = bbox.get("x0", 0) - offset_left
1100
+ bbox["y0"] = bbox.get("y0", 0) - offset_upper
1101
+ # x1, y1 if present
1102
+ if "x1" in bbox:
1103
+ bbox["x1"] = bbox["x1"] - offset_left
1104
+ if "y1" in bbox:
1105
+ bbox["y1"] = bbox["y1"] - offset_upper
1106
+
1107
+ # Adjust artists
1108
+ for artist in result.get("artists", []):
1109
+ if "bbox_px" in artist and artist["bbox_px"]:
1110
+ bbox = artist["bbox_px"]
1111
+ if isinstance(bbox, dict):
1112
+ bbox["x0"] = bbox.get("x0", 0) - offset_left
1113
+ bbox["y0"] = bbox.get("y0", 0) - offset_upper
1114
+ if "x1" in bbox:
1115
+ bbox["x1"] = bbox["x1"] - offset_left
1116
+ if "y1" in bbox:
1117
+ bbox["y1"] = bbox["y1"] - offset_upper
1118
+
1119
+ # Adjust path_px points
1120
+ if "path_px" in artist and artist["path_px"]:
1121
+ artist["path_px"] = [
1122
+ [pt[0] - offset_left, pt[1] - offset_upper]
1123
+ for pt in artist["path_px"]
1124
+ if len(pt) >= 2
1125
+ ]
1126
+
1127
+ return result
1128
+
1129
+
1130
+ def _draw_bbox(ax, bbox: List, color: str, label: str, lw: float = 2) -> None:
1131
+ """Draw a bounding box on an axes with label inside."""
1132
+ import matplotlib.patches as patches
1133
+
1134
+ x0, y0, x1, y1 = bbox
1135
+ width = x1 - x0
1136
+ height = y1 - y0
1137
+ rect = patches.Rectangle((x0, y0), width, height,
1138
+ linewidth=lw, edgecolor=color, facecolor='none')
1139
+ ax.add_patch(rect)
1140
+ # Place label at top-left corner inside the box with background
1141
+ ax.text(x0 + 2, y0 + 2, label, fontsize=6, color="white", va='top', ha='left',
1142
+ fontweight='bold', bbox=dict(boxstyle="round,pad=0.1", facecolor=color, alpha=0.8))
1143
+
1144
+
1145
+ def _format_json_summary(data: Dict, max_depth: int = 2, current_depth: int = 0) -> str:
1146
+ """Format JSON data as summary text with limited depth."""
1147
+ lines = []
1148
+
1149
+ def _format_value(key: str, value, depth: int, prefix: str = "") -> None:
1150
+ indent = " " * depth
1151
+ if depth >= max_depth:
1152
+ if isinstance(value, dict):
1153
+ lines.append(f"{prefix}{indent}{key}: {{...}} ({len(value)} keys)")
1154
+ elif isinstance(value, list):
1155
+ lines.append(f"{prefix}{indent}{key}: [...] ({len(value)} items)")
1156
+ else:
1157
+ val_str = str(value)[:30]
1158
+ if len(str(value)) > 30:
1159
+ val_str += "..."
1160
+ lines.append(f"{prefix}{indent}{key}: {val_str}")
1161
+ elif isinstance(value, dict):
1162
+ lines.append(f"{prefix}{indent}{key}:")
1163
+ for k, v in list(value.items())[:8]: # Limit items
1164
+ _format_value(k, v, depth + 1, prefix)
1165
+ if len(value) > 8:
1166
+ lines.append(f"{prefix}{indent} ... ({len(value) - 8} more)")
1167
+ elif isinstance(value, list):
1168
+ if len(value) > 0 and isinstance(value[0], dict):
1169
+ lines.append(f"{prefix}{indent}{key}: [{len(value)} items]")
1170
+ else:
1171
+ val_str = str(value)[:50]
1172
+ if len(str(value)) > 50:
1173
+ val_str += "..."
1174
+ lines.append(f"{prefix}{indent}{key}: {val_str}")
1175
+ else:
1176
+ val_str = str(value)[:40]
1177
+ if len(str(value)) > 40:
1178
+ val_str += "..."
1179
+ lines.append(f"{prefix}{indent}{key}: {val_str}")
1180
+
1181
+ for key, value in data.items():
1182
+ _format_value(key, value, current_depth)
1183
+
1184
+ return "\n".join(lines[:40]) # Limit total lines
1185
+
1186
+
1187
+ def load_layered_pltz_bundle(bundle_dir: Path) -> Dict[str, Any]:
1188
+ """
1189
+ Load layered .pltz bundle and return merged spec for editor.
1190
+
1191
+ Parameters
1192
+ ----------
1193
+ bundle_dir : Path
1194
+ Path to .pltz.d bundle.
1195
+
1196
+ Returns
1197
+ -------
1198
+ dict
1199
+ Merged bundle data compatible with editor.
1200
+ """
1201
+ bundle_dir = Path(bundle_dir)
1202
+
1203
+ result = {
1204
+ "spec": None,
1205
+ "style": None,
1206
+ "geometry": None,
1207
+ "merged": None, # Combined for backward compatibility
1208
+ "basename": "plot",
1209
+ }
1210
+
1211
+ # Load spec.json
1212
+ spec_path = bundle_dir / "spec.json"
1213
+ if spec_path.exists():
1214
+ with open(spec_path, "r") as f:
1215
+ result["spec"] = json.load(f)
1216
+ result["basename"] = result["spec"].get("plot_id", "plot")
1217
+
1218
+ # Load style.json
1219
+ style_path = bundle_dir / "style.json"
1220
+ if style_path.exists():
1221
+ with open(style_path, "r") as f:
1222
+ result["style"] = json.load(f)
1223
+
1224
+ # Load geometry from cache
1225
+ geometry_path = bundle_dir / "cache" / "geometry_px.json"
1226
+ if geometry_path.exists():
1227
+ with open(geometry_path, "r") as f:
1228
+ result["geometry"] = json.load(f)
1229
+
1230
+ # Create merged view for backward compatibility with editor
1231
+ result["merged"] = merge_layered_bundle(
1232
+ result["spec"], result["style"], result["geometry"]
1233
+ )
1234
+
1235
+ return result
1236
+
1237
+
1238
+ def merge_layered_bundle(
1239
+ spec: Optional[Dict],
1240
+ style: Optional[Dict],
1241
+ geometry: Optional[Dict],
1242
+ ) -> Dict[str, Any]:
1243
+ """
1244
+ Merge spec/style/geometry into old-format compatible dict for editor.
1245
+
1246
+ This provides backward compatibility with editors expecting the old format.
1247
+ """
1248
+ if spec is None:
1249
+ return {}
1250
+
1251
+ merged = {
1252
+ "schema": {"name": "scitex.plt.plot", "version": "2.0.0"},
1253
+ "backend": "mpl",
1254
+ }
1255
+
1256
+ # Merge data section
1257
+ if "data" in spec:
1258
+ merged["data"] = {
1259
+ "source": spec["data"].get("csv", "data.csv"),
1260
+ "path": spec["data"].get("csv", "data.csv"),
1261
+ "hash": spec["data"].get("hash"),
1262
+ }
1263
+
1264
+ # Merge size from style
1265
+ if style and "size" in style:
1266
+ merged["size"] = {
1267
+ "width_mm": style["size"].get("width_mm", 80),
1268
+ "height_mm": style["size"].get("height_mm", 68),
1269
+ "dpi": geometry.get("dpi", get_default_dpi()) if geometry else get_default_dpi(),
1270
+ }
1271
+
1272
+ # Merge axes from spec + style + geometry
1273
+ merged["axes"] = []
1274
+ for ax_spec in spec.get("axes", []):
1275
+ ax_merged = {
1276
+ "id": ax_spec.get("id"),
1277
+ "xlabel": ax_spec.get("labels", {}).get("xlabel"),
1278
+ "ylabel": ax_spec.get("labels", {}).get("ylabel"),
1279
+ "title": ax_spec.get("labels", {}).get("title"),
1280
+ "xlim": ax_spec.get("limits", {}).get("x"),
1281
+ "ylim": ax_spec.get("limits", {}).get("y"),
1282
+ "bbox": ax_spec.get("bbox", {}),
1283
+ }
1284
+
1285
+ # Add geometry bbox_px if available
1286
+ if geometry:
1287
+ for ax_geom in geometry.get("axes", []):
1288
+ if ax_geom.get("id") == ax_spec.get("id"):
1289
+ ax_merged["bbox_px"] = ax_geom.get("bbox_px", {})
1290
+ break
1291
+
1292
+ merged["axes"].append(ax_merged)
1293
+
1294
+ # Merge traces with styles
1295
+ merged["traces"] = []
1296
+ # Build lookup for trace styles by trace_id
1297
+ trace_style_map = {}
1298
+ if style and "traces" in style:
1299
+ for ts in style.get("traces", []):
1300
+ if isinstance(ts, dict):
1301
+ trace_style_map[ts.get("trace_id", "")] = ts
1302
+
1303
+ for trace in spec.get("traces", []):
1304
+ trace_merged = dict(trace)
1305
+ # Add style if available
1306
+ trace_id = trace.get("id", "")
1307
+ if trace_id in trace_style_map:
1308
+ trace_merged.update(trace_style_map[trace_id])
1309
+ merged["traces"].append(trace_merged)
1310
+
1311
+ # Merge theme from style
1312
+ if style and "theme" in style:
1313
+ merged["theme"] = style["theme"]
1314
+
1315
+ # Merge legend from style (for editor compatibility)
1316
+ if style and "legend" in style:
1317
+ legend_style = style["legend"]
1318
+ merged["legend"] = {
1319
+ "visible": legend_style.get("visible", True),
1320
+ # Use "location" key but also provide "loc" for compatibility
1321
+ "loc": legend_style.get("location", "best"),
1322
+ "location": legend_style.get("location", "best"),
1323
+ "frameon": legend_style.get("frameon", False),
1324
+ "fontsize": legend_style.get("fontsize"),
1325
+ "ncols": legend_style.get("ncols", 1),
1326
+ "title": legend_style.get("title"),
1327
+ }
1328
+
1329
+ # Merge hit_regions, selectable_regions, and figure_px from geometry
1330
+ if geometry:
1331
+ if "hit_regions" in geometry:
1332
+ merged["hit_regions"] = geometry["hit_regions"]
1333
+ if "selectable_regions" in geometry:
1334
+ merged["selectable_regions"] = geometry["selectable_regions"]
1335
+ if "figure_px" in geometry:
1336
+ merged["figure_px"] = geometry["figure_px"]
1337
+ if "artists" in geometry:
1338
+ merged["artists"] = geometry["artists"]
1339
+
1340
+ return merged
1341
+
1342
+
1343
+ # EOF