scitex 2.7.0__py3-none-any.whl → 2.7.3__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 (297) 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/fig/__init__.py +352 -0
  79. scitex/{vis → fig}/backend/_parser.py +1 -1
  80. scitex/{vis → fig}/canvas.py +1 -1
  81. scitex/{vis → fig}/editor/_defaults.py +70 -5
  82. scitex/fig/editor/_edit.py +751 -0
  83. scitex/{vis → fig}/editor/_qt_editor.py +181 -1
  84. scitex/fig/editor/flask_editor/_bbox.py +1276 -0
  85. scitex/fig/editor/flask_editor/_core.py +624 -0
  86. scitex/{vis → fig}/editor/flask_editor/_plotter.py +38 -4
  87. scitex/fig/editor/flask_editor/_renderer.py +739 -0
  88. scitex/{vis → fig}/editor/flask_editor/templates/__init__.py +1 -1
  89. scitex/fig/editor/flask_editor/templates/_html.py +834 -0
  90. scitex/fig/editor/flask_editor/templates/_scripts.py +3136 -0
  91. scitex/{vis → fig}/editor/flask_editor/templates/_styles.py +625 -18
  92. scitex/{vis → fig}/io/__init__.py +13 -1
  93. scitex/fig/io/_bundle.py +973 -0
  94. scitex/{vis → fig}/io/_canvas.py +1 -1
  95. scitex/{vis → fig}/io/_data.py +1 -1
  96. scitex/{vis → fig}/io/_export.py +1 -1
  97. scitex/{vis → fig}/io/_load.py +1 -1
  98. scitex/{vis → fig}/io/_panel.py +1 -1
  99. scitex/{vis → fig}/io/_save.py +1 -1
  100. scitex/{vis → fig}/model/__init__.py +1 -1
  101. scitex/{vis → fig}/model/_annotations.py +1 -1
  102. scitex/{vis → fig}/model/_axes.py +1 -1
  103. scitex/{vis → fig}/model/_figure.py +1 -1
  104. scitex/{vis → fig}/model/_guides.py +1 -1
  105. scitex/{vis → fig}/model/_plot.py +1 -1
  106. scitex/{vis → fig}/model/_styles.py +1 -1
  107. scitex/{vis → fig}/utils/__init__.py +1 -1
  108. scitex/io/__init__.py +10 -26
  109. scitex/io/_bundle.py +434 -0
  110. scitex/io/_flush.py +5 -2
  111. scitex/io/_load.py +98 -0
  112. scitex/io/_load_modules/_H5Explorer.py +5 -2
  113. scitex/io/_load_modules/_canvas.py +2 -2
  114. scitex/io/_load_modules/_image.py +3 -4
  115. scitex/io/_load_modules/_txt.py +4 -2
  116. scitex/io/_metadata.py +34 -324
  117. scitex/io/_metadata_modules/__init__.py +46 -0
  118. scitex/io/_metadata_modules/_embed.py +70 -0
  119. scitex/io/_metadata_modules/_read.py +64 -0
  120. scitex/io/_metadata_modules/_utils.py +79 -0
  121. scitex/io/_metadata_modules/embed_metadata_jpeg.py +74 -0
  122. scitex/io/_metadata_modules/embed_metadata_pdf.py +53 -0
  123. scitex/io/_metadata_modules/embed_metadata_png.py +26 -0
  124. scitex/io/_metadata_modules/embed_metadata_svg.py +62 -0
  125. scitex/io/_metadata_modules/read_metadata_jpeg.py +57 -0
  126. scitex/io/_metadata_modules/read_metadata_pdf.py +51 -0
  127. scitex/io/_metadata_modules/read_metadata_png.py +39 -0
  128. scitex/io/_metadata_modules/read_metadata_svg.py +44 -0
  129. scitex/io/_qr_utils.py +5 -3
  130. scitex/io/_save.py +548 -30
  131. scitex/io/_save_modules/_canvas.py +3 -3
  132. scitex/io/_save_modules/_image.py +5 -9
  133. scitex/io/_save_modules/_tex.py +7 -4
  134. scitex/io/utils/h5_to_zarr.py +11 -9
  135. scitex/msword/__init__.py +255 -0
  136. scitex/msword/profiles.py +357 -0
  137. scitex/msword/reader.py +753 -0
  138. scitex/msword/utils.py +289 -0
  139. scitex/msword/writer.py +362 -0
  140. scitex/plt/__init__.py +5 -2
  141. scitex/plt/_subplots/_AxesWrapper.py +6 -6
  142. scitex/plt/_subplots/_AxisWrapper.py +15 -9
  143. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/__init__.py +36 -0
  144. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_labels.py +264 -0
  145. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_metadata.py +213 -0
  146. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin/_visual.py +128 -0
  147. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/__init__.py +59 -0
  148. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_base.py +34 -0
  149. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_scientific.py +593 -0
  150. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_statistical.py +654 -0
  151. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin/_stx_aliases.py +527 -0
  152. scitex/plt/_subplots/_AxisWrapperMixins/_RawMatplotlibMixin.py +321 -0
  153. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/__init__.py +33 -0
  154. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/_base.py +152 -0
  155. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin/_wrappers.py +600 -0
  156. scitex/plt/_subplots/_AxisWrapperMixins/__init__.py +79 -5
  157. scitex/plt/_subplots/_FigWrapper.py +6 -6
  158. scitex/plt/_subplots/_SubplotsWrapper.py +28 -18
  159. scitex/plt/_subplots/_export_as_csv.py +35 -5
  160. scitex/plt/_subplots/_export_as_csv_formatters/__init__.py +8 -0
  161. scitex/plt/_subplots/_export_as_csv_formatters/_format_annotate.py +10 -21
  162. scitex/plt/_subplots/_export_as_csv_formatters/_format_eventplot.py +18 -7
  163. scitex/plt/_subplots/_export_as_csv_formatters/_format_imshow2d.py +28 -12
  164. scitex/plt/_subplots/_export_as_csv_formatters/_format_matshow.py +10 -4
  165. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot_imshow.py +13 -1
  166. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot_kde.py +12 -2
  167. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot_scatter.py +10 -3
  168. scitex/plt/_subplots/_export_as_csv_formatters/_format_quiver.py +10 -4
  169. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_jointplot.py +18 -3
  170. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_lineplot.py +44 -36
  171. scitex/plt/_subplots/_export_as_csv_formatters/_format_sns_pairplot.py +14 -2
  172. scitex/plt/_subplots/_export_as_csv_formatters/_format_streamplot.py +11 -5
  173. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_bar.py +84 -0
  174. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_barh.py +85 -0
  175. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_conf_mat.py +14 -3
  176. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_contour.py +54 -0
  177. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_ecdf.py +14 -2
  178. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_errorbar.py +120 -0
  179. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_heatmap.py +16 -6
  180. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_image.py +29 -19
  181. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_imshow.py +63 -0
  182. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_joyplot.py +22 -5
  183. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_mean_ci.py +18 -14
  184. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_mean_std.py +18 -14
  185. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_median_iqr.py +18 -14
  186. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_raster.py +10 -2
  187. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_scatter.py +51 -0
  188. scitex/plt/_subplots/_export_as_csv_formatters/_format_stx_scatter_hist.py +18 -9
  189. scitex/plt/ax/_plot/_stx_ecdf.py +4 -2
  190. scitex/plt/gallery/_generate.py +421 -14
  191. scitex/plt/io/__init__.py +53 -0
  192. scitex/plt/io/_bundle.py +490 -0
  193. scitex/plt/io/_layered_bundle.py +1343 -0
  194. scitex/plt/styles/SCITEX_STYLE.yaml +26 -0
  195. scitex/plt/styles/__init__.py +14 -0
  196. scitex/plt/styles/presets.py +78 -0
  197. scitex/plt/utils/__init__.py +13 -1
  198. scitex/plt/utils/_collect_figure_metadata.py +10 -14
  199. scitex/plt/utils/_configure_mpl.py +6 -18
  200. scitex/plt/utils/_crop.py +32 -14
  201. scitex/plt/utils/_csv_column_naming.py +54 -0
  202. scitex/plt/utils/_figure_mm.py +116 -1
  203. scitex/plt/utils/_hitmap.py +1643 -0
  204. scitex/plt/utils/metadata/__init__.py +25 -0
  205. scitex/plt/utils/metadata/_core.py +9 -10
  206. scitex/plt/utils/metadata/_dimensions.py +6 -3
  207. scitex/plt/utils/metadata/_editable_export.py +405 -0
  208. scitex/plt/utils/metadata/_geometry_extraction.py +570 -0
  209. scitex/schema/__init__.py +109 -16
  210. scitex/schema/_canvas.py +1 -1
  211. scitex/schema/_plot.py +1015 -0
  212. scitex/schema/_stats.py +2 -2
  213. scitex/stats/__init__.py +117 -0
  214. scitex/stats/io/__init__.py +29 -0
  215. scitex/stats/io/_bundle.py +156 -0
  216. scitex/tex/__init__.py +4 -0
  217. scitex/tex/_export.py +890 -0
  218. {scitex-2.7.0.dist-info → scitex-2.7.3.dist-info}/METADATA +11 -1
  219. {scitex-2.7.0.dist-info → scitex-2.7.3.dist-info}/RECORD +238 -170
  220. scitex/io/memo.md +0 -2827
  221. scitex/plt/REQUESTS.md +0 -191
  222. scitex/plt/_subplots/TODO.md +0 -53
  223. scitex/plt/_subplots/_AxisWrapperMixins/_AdjustmentMixin.py +0 -559
  224. scitex/plt/_subplots/_AxisWrapperMixins/_MatplotlibPlotMixin.py +0 -1609
  225. scitex/plt/_subplots/_AxisWrapperMixins/_SeabornMixin.py +0 -447
  226. scitex/plt/templates/research-master/scitex/vis/gallery/area/fill_between.json +0 -110
  227. scitex/plt/templates/research-master/scitex/vis/gallery/area/fill_betweenx.json +0 -88
  228. scitex/plt/templates/research-master/scitex/vis/gallery/area/stx_fill_between.json +0 -103
  229. scitex/plt/templates/research-master/scitex/vis/gallery/area/stx_fillv.json +0 -106
  230. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/bar.json +0 -92
  231. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/barh.json +0 -92
  232. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/boxplot.json +0 -92
  233. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_bar.json +0 -84
  234. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_barh.json +0 -84
  235. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_box.json +0 -83
  236. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_boxplot.json +0 -93
  237. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_violin.json +0 -91
  238. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/stx_violinplot.json +0 -91
  239. scitex/plt/templates/research-master/scitex/vis/gallery/categorical/violinplot.json +0 -91
  240. scitex/plt/templates/research-master/scitex/vis/gallery/contour/contour.json +0 -97
  241. scitex/plt/templates/research-master/scitex/vis/gallery/contour/contourf.json +0 -98
  242. scitex/plt/templates/research-master/scitex/vis/gallery/contour/stx_contour.json +0 -84
  243. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/hist.json +0 -101
  244. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/hist2d.json +0 -96
  245. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/stx_ecdf.json +0 -95
  246. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/stx_joyplot.json +0 -95
  247. scitex/plt/templates/research-master/scitex/vis/gallery/distribution/stx_kde.json +0 -93
  248. scitex/plt/templates/research-master/scitex/vis/gallery/grid/imshow.json +0 -95
  249. scitex/plt/templates/research-master/scitex/vis/gallery/grid/matshow.json +0 -95
  250. scitex/plt/templates/research-master/scitex/vis/gallery/grid/stx_conf_mat.json +0 -83
  251. scitex/plt/templates/research-master/scitex/vis/gallery/grid/stx_heatmap.json +0 -92
  252. scitex/plt/templates/research-master/scitex/vis/gallery/grid/stx_image.json +0 -121
  253. scitex/plt/templates/research-master/scitex/vis/gallery/grid/stx_imshow.json +0 -84
  254. scitex/plt/templates/research-master/scitex/vis/gallery/line/plot.json +0 -110
  255. scitex/plt/templates/research-master/scitex/vis/gallery/line/step.json +0 -92
  256. scitex/plt/templates/research-master/scitex/vis/gallery/line/stx_line.json +0 -95
  257. scitex/plt/templates/research-master/scitex/vis/gallery/line/stx_shaded_line.json +0 -96
  258. scitex/plt/templates/research-master/scitex/vis/gallery/scatter/hexbin.json +0 -95
  259. scitex/plt/templates/research-master/scitex/vis/gallery/scatter/scatter.json +0 -95
  260. scitex/plt/templates/research-master/scitex/vis/gallery/scatter/stem.json +0 -92
  261. scitex/plt/templates/research-master/scitex/vis/gallery/scatter/stx_scatter.json +0 -84
  262. scitex/plt/templates/research-master/scitex/vis/gallery/special/pie.json +0 -94
  263. scitex/plt/templates/research-master/scitex/vis/gallery/special/stx_raster.json +0 -109
  264. scitex/plt/templates/research-master/scitex/vis/gallery/special/stx_rectangle.json +0 -108
  265. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/errorbar.json +0 -93
  266. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/stx_errorbar.json +0 -84
  267. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/stx_mean_ci.json +0 -96
  268. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/stx_mean_std.json +0 -96
  269. scitex/plt/templates/research-master/scitex/vis/gallery/statistical/stx_median_iqr.json +0 -96
  270. scitex/plt/templates/research-master/scitex/vis/gallery/vector/quiver.json +0 -99
  271. scitex/plt/templates/research-master/scitex/vis/gallery/vector/streamplot.json +0 -100
  272. scitex/vis/__init__.py +0 -177
  273. scitex/vis/editor/_edit.py +0 -390
  274. scitex/vis/editor/flask_editor/_bbox.py +0 -529
  275. scitex/vis/editor/flask_editor/_core.py +0 -168
  276. scitex/vis/editor/flask_editor/_renderer.py +0 -393
  277. scitex/vis/editor/flask_editor/templates/_html.py +0 -513
  278. scitex/vis/editor/flask_editor/templates/_scripts.py +0 -1261
  279. /scitex/{vis → fig}/README.md +0 -0
  280. /scitex/{vis → fig}/backend/__init__.py +0 -0
  281. /scitex/{vis → fig}/backend/_export.py +0 -0
  282. /scitex/{vis → fig}/backend/_render.py +0 -0
  283. /scitex/{vis → fig}/docs/CANVAS_ARCHITECTURE.md +0 -0
  284. /scitex/{vis → fig}/editor/__init__.py +0 -0
  285. /scitex/{vis → fig}/editor/_dearpygui_editor.py +0 -0
  286. /scitex/{vis → fig}/editor/_flask_editor.py +0 -0
  287. /scitex/{vis → fig}/editor/_mpl_editor.py +0 -0
  288. /scitex/{vis → fig}/editor/_tkinter_editor.py +0 -0
  289. /scitex/{vis → fig}/editor/flask_editor/__init__.py +0 -0
  290. /scitex/{vis → fig}/editor/flask_editor/_utils.py +0 -0
  291. /scitex/{vis → fig}/io/_directory.py +0 -0
  292. /scitex/{vis → fig}/model/_plot_types.py +0 -0
  293. /scitex/{vis → fig}/utils/_defaults.py +0 -0
  294. /scitex/{vis → fig}/utils/_validate.py +0 -0
  295. {scitex-2.7.0.dist-info → scitex-2.7.3.dist-info}/WHEEL +0 -0
  296. {scitex-2.7.0.dist-info → scitex-2.7.3.dist-info}/entry_points.txt +0 -0
  297. {scitex-2.7.0.dist-info → scitex-2.7.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,973 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # Timestamp: "2025-12-14 (ywatanabe)"
4
+ # File: /home/ywatanabe/proj/scitex-code/src/scitex/fig/io/_bundle.py
5
+
6
+ """
7
+ SciTeX .figz Bundle I/O - Figure-specific bundle operations.
8
+
9
+ Handles:
10
+ - Figure specification validation
11
+ - Panel composition and layout
12
+ - Nested .pltz bundle management
13
+ - Export file handling
14
+ """
15
+
16
+ import json
17
+ import shutil
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List
20
+
21
+ __all__ = [
22
+ "validate_figz_spec",
23
+ "load_figz_bundle",
24
+ "save_figz_bundle",
25
+ "FIGZ_SCHEMA_SPEC",
26
+ ]
27
+
28
+ # Schema specification for .figz bundles
29
+ FIGZ_SCHEMA_SPEC = {
30
+ "name": "scitex.fig.figure",
31
+ "version": "1.0.0",
32
+ "required_fields": ["schema"],
33
+ "optional_fields": ["figure", "panels", "notations"],
34
+ }
35
+
36
+
37
+ def validate_figz_spec(spec: Dict[str, Any]) -> List[str]:
38
+ """Validate .figz-specific fields.
39
+
40
+ Args:
41
+ spec: The specification dictionary to validate.
42
+
43
+ Returns:
44
+ List of validation error messages (empty if valid).
45
+ """
46
+ errors = []
47
+
48
+ if "panels" in spec:
49
+ panels = spec["panels"]
50
+ if not isinstance(panels, list):
51
+ errors.append("'panels' must be a list")
52
+ else:
53
+ for i, panel in enumerate(panels):
54
+ if not isinstance(panel, dict):
55
+ errors.append(f"panels[{i}] must be a dictionary")
56
+ continue
57
+ if "id" not in panel:
58
+ errors.append(f"panels[{i}].id is required")
59
+
60
+ if "figure" in spec:
61
+ figure = spec["figure"]
62
+ if not isinstance(figure, dict):
63
+ errors.append("'figure' must be a dictionary")
64
+
65
+ return errors
66
+
67
+
68
+ def load_figz_bundle(bundle_dir: Path) -> Dict[str, Any]:
69
+ """Load .figz bundle contents from directory.
70
+
71
+ Supports both:
72
+ - New format: spec.json + style.json (separate semantic/appearance)
73
+ - Legacy format: {basename}.json (embedded styles)
74
+
75
+ Args:
76
+ bundle_dir: Path to the bundle directory.
77
+
78
+ Returns:
79
+ Dictionary with loaded bundle contents:
80
+ - spec: Figure specification (semantic)
81
+ - style: Figure style (appearance)
82
+ - plots: Dict of nested pltz bundles
83
+ - basename: Base filename
84
+ """
85
+ result = {}
86
+ bundle_dir = Path(bundle_dir)
87
+
88
+ # Determine basename from directory name
89
+ basename = bundle_dir.stem.replace(".figz", "")
90
+ result["basename"] = basename
91
+
92
+ # Try to load spec.json (new format) first
93
+ spec_file = bundle_dir / "spec.json"
94
+ if spec_file.exists():
95
+ with open(spec_file, "r") as f:
96
+ result["spec"] = json.load(f)
97
+ else:
98
+ # Fallback to {basename}.json (legacy format)
99
+ legacy_file = bundle_dir / f"{basename}.json"
100
+ if legacy_file.exists():
101
+ with open(legacy_file, "r") as f:
102
+ result["spec"] = json.load(f)
103
+ else:
104
+ # Try any .json file
105
+ for f in bundle_dir.glob("*.json"):
106
+ if not f.name.startswith('.') and f.name != "style.json":
107
+ with open(f, "r") as fp:
108
+ result["spec"] = json.load(fp)
109
+ break
110
+ else:
111
+ result["spec"] = None
112
+
113
+ # Load style.json if exists
114
+ style_file = bundle_dir / "style.json"
115
+ if style_file.exists():
116
+ with open(style_file, "r") as f:
117
+ result["style"] = json.load(f)
118
+ else:
119
+ # Extract from embedded styles in spec (legacy)
120
+ if result.get("spec"):
121
+ figure = result["spec"].get("figure", {})
122
+ if "styles" in figure:
123
+ result["style"] = figure["styles"]
124
+ else:
125
+ result["style"] = {}
126
+ else:
127
+ result["style"] = {}
128
+
129
+ # Load nested .pltz bundles
130
+ result["plots"] = {}
131
+
132
+ # Load from .pltz.d directories
133
+ for pltz_dir in bundle_dir.glob("*.pltz.d"):
134
+ plot_name = pltz_dir.stem.replace(".pltz", "")
135
+ from scitex.io._bundle import load_bundle
136
+ result["plots"][plot_name] = load_bundle(pltz_dir)
137
+
138
+ # Load from .pltz ZIP files
139
+ for pltz_zip in bundle_dir.glob("*.pltz"):
140
+ if pltz_zip.is_file():
141
+ plot_name = pltz_zip.stem
142
+ from scitex.io._bundle import load_bundle
143
+ result["plots"][plot_name] = load_bundle(pltz_zip)
144
+
145
+ return result
146
+
147
+
148
+ def save_figz_bundle(data: Dict[str, Any], dir_path: Path) -> None:
149
+ """Save .figz bundle contents to directory.
150
+
151
+ Structure:
152
+ figure.figz.d/
153
+ spec.json # Figure-level specification
154
+ style.json # Figure-level style (optional)
155
+ exports/ # Figure-level exports
156
+ figure.png
157
+ figure.svg
158
+ figure_hitmap.png
159
+ figure_overview.png
160
+ cache/ # Figure-level cache
161
+ geometry_px.json # Combined geometry for all panels
162
+ render_manifest.json
163
+ panels/ # Nested panel bundles (or *.pltz.d at root)
164
+ A.pltz.d/
165
+ B.pltz.d/
166
+ README.md
167
+
168
+ Args:
169
+ data: Bundle data dictionary.
170
+ dir_path: Path to the bundle directory.
171
+ """
172
+ import logging
173
+ logger = logging.getLogger("scitex")
174
+
175
+ # Get basename from directory name (e.g., "Figure1" from "Figure1.figz.d")
176
+ basename = dir_path.stem.replace(".figz", "")
177
+
178
+ # Create directories
179
+ exports_dir = dir_path / "exports"
180
+ cache_dir = dir_path / "cache"
181
+ exports_dir.mkdir(parents=True, exist_ok=True)
182
+ cache_dir.mkdir(parents=True, exist_ok=True)
183
+
184
+ # Split spec into spec.json (semantic) and style.json (appearance)
185
+ spec = data.get("spec", {})
186
+ style = data.get("style", {})
187
+
188
+ # Extract style from spec.figure.styles if not provided separately
189
+ figure_data = spec.get("figure", {})
190
+ if not style and "styles" in figure_data:
191
+ style = figure_data.get("styles", {})
192
+
193
+ # Build clean spec (semantic data only)
194
+ clean_spec = {
195
+ "schema": spec.get("schema", {"name": "scitex.fig.figure", "version": "1.0.0"}),
196
+ "figure": {
197
+ "id": figure_data.get("id", "figure"),
198
+ "title": figure_data.get("title", ""),
199
+ "caption": figure_data.get("caption", ""),
200
+ },
201
+ "panels": spec.get("panels", []),
202
+ }
203
+ if "notations" in spec:
204
+ clean_spec["notations"] = spec["notations"]
205
+
206
+ # Build style (appearance data)
207
+ figz_style = {
208
+ "schema": {"name": "scitex.fig.style", "version": "1.0.0"},
209
+ "size": style.get("size", {"width_mm": 180, "height_mm": 120}),
210
+ "background": style.get("background", "#ffffff"),
211
+ "theme": style.get("theme", {"mode": "light"}),
212
+ "panel_labels": style.get("panel_labels", {
213
+ "visible": True,
214
+ "fontsize": 12,
215
+ "fontweight": "bold",
216
+ "position": "top-left",
217
+ }),
218
+ }
219
+
220
+ # Save spec.json (semantic)
221
+ spec_file = dir_path / "spec.json"
222
+ with open(spec_file, "w") as f:
223
+ json.dump(clean_spec, f, indent=2)
224
+
225
+ # Save style.json (appearance)
226
+ style_file = dir_path / "style.json"
227
+ with open(style_file, "w") as f:
228
+ json.dump(figz_style, f, indent=2)
229
+
230
+ # Also save as {basename}.json for backward compatibility (full spec with embedded style)
231
+ compat_spec = dict(clean_spec)
232
+ compat_spec["figure"]["styles"] = {
233
+ "size": figz_style["size"],
234
+ "background": figz_style["background"],
235
+ }
236
+ compat_spec_file = dir_path / f"{basename}.json"
237
+ with open(compat_spec_file, "w") as f:
238
+ json.dump(compat_spec, f, indent=2)
239
+
240
+ # Save exports to exports/ directory
241
+ _save_figz_exports(data, exports_dir, spec, basename)
242
+
243
+ # Copy nested .pltz bundles directly (preserving all files)
244
+ if "plots" in data:
245
+ _copy_nested_pltz_bundles(data["plots"], dir_path)
246
+
247
+ # Generate composed figure in exports/ (Figure1.png, Figure1.svg)
248
+ try:
249
+ _generate_composed_figure(dir_path, spec, basename)
250
+ except Exception as e:
251
+ logger.debug(f"Could not generate composed figure: {e}")
252
+
253
+ # Generate figz overview in exports/
254
+ try:
255
+ _generate_figz_overview(dir_path, spec, data, basename)
256
+ except Exception as e:
257
+ logger.debug(f"Could not generate figz overview: {e}")
258
+
259
+ # Generate figure-level geometry cache
260
+ try:
261
+ _generate_figz_geometry_cache(dir_path, spec, basename)
262
+ except Exception as e:
263
+ logger.debug(f"Could not generate figz geometry cache: {e}")
264
+
265
+ # Generate README.md
266
+ try:
267
+ _generate_figz_readme(dir_path, spec, data, basename)
268
+ except Exception as e:
269
+ logger.debug(f"Could not generate figz README: {e}")
270
+
271
+
272
+ def _save_figz_exports(data: Dict[str, Any], exports_dir: Path, spec: Dict, basename: str) -> None:
273
+ """Save figure-level export files to exports/ directory.
274
+
275
+ Args:
276
+ data: Bundle data containing PNG/SVG/PDF bytes or paths.
277
+ exports_dir: Path to exports/ directory.
278
+ spec: Figure specification.
279
+ basename: Base filename for exports.
280
+ """
281
+ for fmt in ["png", "svg", "pdf"]:
282
+ if fmt not in data:
283
+ continue
284
+
285
+ out_file = exports_dir / f"{basename}.{fmt}"
286
+ export_data = data[fmt]
287
+
288
+ if isinstance(export_data, bytes):
289
+ with open(out_file, "wb") as f:
290
+ f.write(export_data)
291
+ elif isinstance(export_data, (str, Path)) and Path(export_data).exists():
292
+ shutil.copy(export_data, out_file)
293
+
294
+ # Embed metadata into PNG and PDF files
295
+ if out_file.exists() and spec:
296
+ try:
297
+ _embed_metadata_in_export(out_file, spec, fmt)
298
+ except Exception as e:
299
+ import logging
300
+ logging.getLogger("scitex").debug(
301
+ f"Could not embed metadata in {out_file}: {e}"
302
+ )
303
+
304
+
305
+ def _save_exports(data: Dict[str, Any], dir_path: Path, spec: Dict, basename: str = "figure") -> None:
306
+ """Save export files (PNG, SVG, PDF) with embedded metadata. (Legacy - root level)"""
307
+ for fmt in ["png", "svg", "pdf"]:
308
+ if fmt not in data:
309
+ continue
310
+
311
+ out_file = dir_path / f"{basename}.{fmt}"
312
+ export_data = data[fmt]
313
+
314
+ if isinstance(export_data, bytes):
315
+ with open(out_file, "wb") as f:
316
+ f.write(export_data)
317
+ elif isinstance(export_data, (str, Path)) and Path(export_data).exists():
318
+ shutil.copy(export_data, out_file)
319
+
320
+ # Embed metadata into PNG and PDF files
321
+ if out_file.exists() and spec:
322
+ try:
323
+ _embed_metadata_in_export(out_file, spec, fmt)
324
+ except Exception as e:
325
+ import logging
326
+ logging.getLogger("scitex").debug(
327
+ f"Could not embed metadata in {out_file}: {e}"
328
+ )
329
+
330
+
331
+ def _copy_nested_pltz_bundles(plots: Dict[str, Any], dir_path: Path) -> None:
332
+ """Copy nested .pltz bundles directly, preserving all files.
333
+
334
+ Args:
335
+ plots: Dict mapping panel IDs to either:
336
+ - source_path: Path to existing .pltz.d directory
337
+ - bundle_data: Dict with spec/data (will use save_bundle)
338
+ dir_path: Target figz directory.
339
+ """
340
+ for panel_id, plot_source in plots.items():
341
+ target_path = dir_path / f"{panel_id}.pltz.d"
342
+
343
+ if isinstance(plot_source, (str, Path)):
344
+ # Direct copy from source path
345
+ source_path = Path(plot_source)
346
+ if source_path.exists() and source_path.is_dir():
347
+ if target_path.exists():
348
+ shutil.rmtree(target_path)
349
+ shutil.copytree(source_path, target_path)
350
+ elif isinstance(plot_source, dict):
351
+ # Check if it has source_path for direct copy
352
+ if "source_path" in plot_source:
353
+ source_path = Path(plot_source["source_path"])
354
+ if source_path.exists() and source_path.is_dir():
355
+ if target_path.exists():
356
+ shutil.rmtree(target_path)
357
+ shutil.copytree(source_path, target_path)
358
+ else:
359
+ # Fallback to save_bundle (will lose images)
360
+ from scitex.io._bundle import save_bundle, BundleType
361
+ save_bundle(plot_source, target_path, bundle_type=BundleType.PLTZ)
362
+
363
+
364
+ def _generate_figz_overview(dir_path: Path, spec: Dict, data: Dict, basename: str) -> None:
365
+ """Generate overview image for figz bundle showing panels with hitmaps, overlays, and bboxes.
366
+
367
+ Args:
368
+ dir_path: Bundle directory path.
369
+ spec: Bundle specification.
370
+ data: Bundle data dictionary.
371
+ basename: Base filename for bundle files.
372
+ """
373
+ import matplotlib.pyplot as plt
374
+ import matplotlib.gridspec as gridspec
375
+ import matplotlib.patches as patches
376
+ from PIL import Image
377
+ import numpy as np
378
+ import warnings
379
+
380
+ # Find all panel directories
381
+ panel_dirs = sorted(dir_path.glob("*.pltz.d"))
382
+ n_panels = len(panel_dirs)
383
+
384
+ if n_panels == 0:
385
+ return
386
+
387
+ # Create figure with 2 rows per panel:
388
+ # Row 1: Plot | Hitmap | Overlay
389
+ # Row 2: Bboxes | (empty) | (empty)
390
+ fig_width = 15
391
+ fig_height = 6 * n_panels + 1
392
+ fig = plt.figure(figsize=(fig_width, fig_height), facecolor="white")
393
+
394
+ # Title
395
+ title = spec.get("figure", {}).get("title", basename)
396
+ fig.suptitle(f"Figure Overview: {title}", fontsize=14, fontweight="bold", y=0.99)
397
+
398
+ # Create gridspec - 2 rows per panel, 3 columns
399
+ gs = gridspec.GridSpec(n_panels * 2, 3, figure=fig, hspace=0.3, wspace=0.15,
400
+ height_ratios=[1, 1] * n_panels)
401
+
402
+ # Add each panel
403
+ for idx, panel_dir in enumerate(panel_dirs):
404
+ panel_id = panel_dir.stem.replace(".pltz", "")
405
+ row_base = idx * 2 # Two rows per panel
406
+
407
+ # Find PNG in panel directory (check exports/ first for layered format, then root)
408
+ png_files = list(panel_dir.glob("exports/*.png"))
409
+ if not png_files:
410
+ png_files = list(panel_dir.glob("*.png"))
411
+ main_pngs = [f for f in png_files if "_hitmap" not in f.name and "_overview" not in f.name]
412
+
413
+ # Find hitmap PNG
414
+ hitmap_files = list(panel_dir.glob("exports/*_hitmap.png"))
415
+ if not hitmap_files:
416
+ hitmap_files = list(panel_dir.glob("*_hitmap.png"))
417
+
418
+ # Load geometry for bboxes
419
+ geometry_data = {}
420
+ geometry_path = panel_dir / "cache" / "geometry_px.json"
421
+ if geometry_path.exists():
422
+ with open(geometry_path, "r") as f:
423
+ geometry_data = json.load(f)
424
+
425
+ # === Row 1: Plot | Hitmap | Overlay ===
426
+ # Left subplot: main image
427
+ ax_main = fig.add_subplot(gs[row_base, 0])
428
+ ax_main.set_title(f"Panel {panel_id}", fontweight="bold", fontsize=11)
429
+
430
+ main_img = None
431
+ if main_pngs:
432
+ main_img = Image.open(main_pngs[0])
433
+ ax_main.imshow(main_img)
434
+ else:
435
+ ax_main.text(0.5, 0.5, "No image", ha="center", va="center", transform=ax_main.transAxes)
436
+ ax_main.axis("off")
437
+
438
+ # Middle subplot: hitmap
439
+ ax_hitmap = fig.add_subplot(gs[row_base, 1])
440
+ ax_hitmap.set_title(f"Hitmap {panel_id}", fontweight="bold", fontsize=11)
441
+
442
+ hitmap_img = None
443
+ if hitmap_files:
444
+ hitmap_img = Image.open(hitmap_files[0])
445
+ ax_hitmap.imshow(hitmap_img)
446
+ else:
447
+ ax_hitmap.text(0.5, 0.5, "No hitmap", ha="center", va="center", transform=ax_hitmap.transAxes)
448
+ ax_hitmap.axis("off")
449
+
450
+ # Right subplot: overlay
451
+ ax_overlay = fig.add_subplot(gs[row_base, 2])
452
+ ax_overlay.set_title(f"Overlay {panel_id}", fontweight="bold", fontsize=11)
453
+
454
+ if main_img is not None:
455
+ ax_overlay.imshow(main_img)
456
+ if hitmap_img is not None:
457
+ hitmap_rgba = hitmap_img.convert("RGBA")
458
+ hitmap_array = np.array(hitmap_rgba)
459
+ # Create semi-transparent overlay
460
+ hitmap_array[:, :, 3] = (hitmap_array[:, :, 3] * 0.5).astype(np.uint8)
461
+ ax_overlay.imshow(hitmap_array, alpha=0.5)
462
+ else:
463
+ ax_overlay.text(0.5, 0.5, "No overlay", ha="center", va="center", transform=ax_overlay.transAxes)
464
+ ax_overlay.axis("off")
465
+
466
+ # === Row 2: Bboxes ===
467
+ ax_bboxes = fig.add_subplot(gs[row_base + 1, 0])
468
+ ax_bboxes.set_title(f"Bboxes {panel_id}", fontweight="bold", fontsize=11)
469
+
470
+ if main_img is not None:
471
+ ax_bboxes.imshow(main_img)
472
+ # Draw bboxes from geometry
473
+ _draw_bboxes_from_geometry(ax_bboxes, geometry_data)
474
+ else:
475
+ ax_bboxes.text(0.5, 0.5, "No image", ha="center", va="center", transform=ax_bboxes.transAxes)
476
+ ax_bboxes.axis("off")
477
+
478
+ # Info panel
479
+ ax_info = fig.add_subplot(gs[row_base + 1, 1:])
480
+ ax_info.set_title(f"Info {panel_id}", fontweight="bold", fontsize=11)
481
+ ax_info.axis("off")
482
+
483
+ # Show spec/style summary
484
+ spec_path = panel_dir / "spec.json"
485
+ style_path = panel_dir / "style.json"
486
+ info_text = ""
487
+
488
+ if spec_path.exists():
489
+ with open(spec_path, "r") as f:
490
+ spec_data = json.load(f)
491
+ info_text += f"Axes: {len(spec_data.get('axes', []))}\n"
492
+ info_text += f"Traces: {len(spec_data.get('traces', []))}\n"
493
+
494
+ if style_path.exists():
495
+ with open(style_path, "r") as f:
496
+ style_data = json.load(f)
497
+ size = style_data.get("size", {})
498
+ info_text += f"Size: {size.get('width_mm', 0):.1f} × {size.get('height_mm', 0):.1f} mm\n"
499
+ info_text += f"Theme: {style_data.get('theme', {}).get('mode', 'light')}\n"
500
+
501
+ manifest_path = panel_dir / "cache" / "render_manifest.json"
502
+ if manifest_path.exists():
503
+ with open(manifest_path, "r") as f:
504
+ manifest_data = json.load(f)
505
+ info_text += f"DPI: {manifest_data.get('dpi', 300)}\n"
506
+ render_px = manifest_data.get("render_px", [0, 0])
507
+ info_text += f"Pixels: {render_px[0]} × {render_px[1]}\n"
508
+
509
+ ax_info.text(0.02, 0.98, info_text, transform=ax_info.transAxes,
510
+ fontsize=10, fontfamily="monospace", verticalalignment="top",
511
+ bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.5))
512
+
513
+ # Save overview to exports/ directory
514
+ exports_dir = dir_path / "exports"
515
+ exports_dir.mkdir(parents=True, exist_ok=True)
516
+ overview_path = exports_dir / f"{basename}_overview.png"
517
+ with warnings.catch_warnings():
518
+ warnings.filterwarnings("ignore", message=".*tight_layout.*")
519
+ fig.savefig(overview_path, dpi=150, bbox_inches="tight", facecolor="white")
520
+ plt.close(fig)
521
+
522
+
523
+ def _draw_bboxes_from_geometry(ax, geometry_data: Dict) -> None:
524
+ """Draw bboxes from geometry data on an axes.
525
+
526
+ Args:
527
+ ax: Matplotlib axes.
528
+ geometry_data: Geometry data dictionary.
529
+ """
530
+ import matplotlib.patches as patches
531
+
532
+ colors = ["red", "blue", "green", "orange", "purple", "cyan"]
533
+ selectable = geometry_data.get("selectable_regions", {})
534
+
535
+ for ax_idx, ax_region in enumerate(selectable.get("axes", [])):
536
+ color = colors[ax_idx % len(colors)]
537
+
538
+ # Title bbox
539
+ if "title" in ax_region:
540
+ bbox = ax_region["title"].get("bbox_px", [])
541
+ if len(bbox) == 4:
542
+ _draw_single_bbox(ax, bbox, color, "title")
543
+
544
+ # xlabel bbox
545
+ if "xlabel" in ax_region:
546
+ bbox = ax_region["xlabel"].get("bbox_px", [])
547
+ if len(bbox) == 4:
548
+ _draw_single_bbox(ax, bbox, color, "xlabel")
549
+
550
+ # ylabel bbox
551
+ if "ylabel" in ax_region:
552
+ bbox = ax_region["ylabel"].get("bbox_px", [])
553
+ if len(bbox) == 4:
554
+ _draw_single_bbox(ax, bbox, color, "ylabel")
555
+
556
+ # xaxis spine
557
+ if "xaxis" in ax_region and "spine" in ax_region["xaxis"]:
558
+ bbox = ax_region["xaxis"]["spine"].get("bbox_px", [])
559
+ if len(bbox) == 4:
560
+ _draw_single_bbox(ax, bbox, "gray", "xaxis", lw=1)
561
+
562
+ # yaxis spine
563
+ if "yaxis" in ax_region and "spine" in ax_region["yaxis"]:
564
+ bbox = ax_region["yaxis"]["spine"].get("bbox_px", [])
565
+ if len(bbox) == 4:
566
+ _draw_single_bbox(ax, bbox, "gray", "yaxis", lw=1)
567
+
568
+ # legend bbox
569
+ if "legend" in ax_region:
570
+ bbox = ax_region["legend"].get("bbox_px", [])
571
+ if len(bbox) == 4:
572
+ _draw_single_bbox(ax, bbox, "magenta", "legend")
573
+
574
+
575
+ def _draw_single_bbox(ax, bbox: List, color: str, label: str, lw: int = 2) -> None:
576
+ """Draw a single bbox rectangle on axes.
577
+
578
+ Args:
579
+ ax: Matplotlib axes.
580
+ bbox: [x0, y0, x1, y1] bounding box (corner coordinates).
581
+ color: Rectangle color.
582
+ label: Label text.
583
+ lw: Line width.
584
+ """
585
+ import matplotlib.patches as patches
586
+
587
+ # bbox is [x0, y0, x1, y1] format
588
+ x0, y0, x1, y1 = bbox
589
+ width = x1 - x0
590
+ height = y1 - y0
591
+ rect = patches.Rectangle((x0, y0), width, height,
592
+ linewidth=lw, edgecolor=color, facecolor='none')
593
+ ax.add_patch(rect)
594
+ # Add label
595
+ ax.text(x0 + 2, y0 + height / 2, label, fontsize=6, color=color, fontweight="bold")
596
+
597
+
598
+ def _generate_composed_figure(dir_path: Path, spec: Dict, basename: str) -> None:
599
+ """Generate composed figure from panel images.
600
+
601
+ Composes all panel PNG images into a single figure based on the layout
602
+ specified in the figz spec.
603
+
604
+ Args:
605
+ dir_path: Bundle directory path.
606
+ spec: Bundle specification with panel layout.
607
+ basename: Base filename for exports.
608
+ """
609
+ from PIL import Image
610
+ import warnings
611
+
612
+ exports_dir = dir_path / "exports"
613
+ exports_dir.mkdir(parents=True, exist_ok=True)
614
+
615
+ # Load style from style.json if exists, else from spec
616
+ style_file = dir_path / "style.json"
617
+ if style_file.exists():
618
+ with open(style_file, "r") as f:
619
+ style = json.load(f)
620
+ size = style.get("size", {})
621
+ background = style.get("background", "#ffffff")
622
+ else:
623
+ # Fallback to embedded styles in spec
624
+ figure = spec.get("figure", {})
625
+ styles = figure.get("styles", {})
626
+ size = styles.get("size", {})
627
+ background = styles.get("background", "#ffffff")
628
+
629
+ fig_width_mm = size.get("width_mm", 180)
630
+ fig_height_mm = size.get("height_mm", 120)
631
+
632
+ # Use 300 DPI for composition
633
+ dpi = 300
634
+ mm_to_inch = 1 / 25.4
635
+ fig_width_px = int(fig_width_mm * mm_to_inch * dpi)
636
+ fig_height_px = int(fig_height_mm * mm_to_inch * dpi)
637
+
638
+ # Create canvas
639
+ canvas = Image.new("RGB", (fig_width_px, fig_height_px), background)
640
+
641
+ # Get panels from spec
642
+ panels = spec.get("panels", [])
643
+
644
+ for panel in panels:
645
+ panel_id = panel.get("id", "")
646
+ plot_ref = panel.get("plot", "")
647
+
648
+ # Find the panel's pltz bundle
649
+ if plot_ref.endswith(".pltz.d"):
650
+ panel_dir = dir_path / plot_ref
651
+ else:
652
+ panel_dir = dir_path / f"{panel_id}.pltz.d"
653
+
654
+ if not panel_dir.exists():
655
+ continue
656
+
657
+ # Find panel PNG in exports/
658
+ panel_png = None
659
+ exports_subdir = panel_dir / "exports"
660
+ if exports_subdir.exists():
661
+ for png_file in exports_subdir.glob("*.png"):
662
+ if "_hitmap" not in png_file.name and "_overview" not in png_file.name:
663
+ panel_png = png_file
664
+ break
665
+
666
+ # Fallback: look in panel root
667
+ if not panel_png:
668
+ for png_file in panel_dir.glob("*.png"):
669
+ if "_hitmap" not in png_file.name and "_overview" not in png_file.name:
670
+ panel_png = png_file
671
+ break
672
+
673
+ if not panel_png or not panel_png.exists():
674
+ continue
675
+
676
+ # Load panel image
677
+ panel_img = Image.open(panel_png)
678
+
679
+ # Get panel position and size from spec
680
+ pos = panel.get("position", {})
681
+ panel_size = panel.get("size", {})
682
+
683
+ x_mm = pos.get("x_mm", 0)
684
+ y_mm = pos.get("y_mm", 0)
685
+ width_mm = panel_size.get("width_mm", 80)
686
+ height_mm = panel_size.get("height_mm", 68)
687
+
688
+ # Convert to pixels
689
+ x_px = int(x_mm * mm_to_inch * dpi)
690
+ y_px = int(y_mm * mm_to_inch * dpi)
691
+ target_width = int(width_mm * mm_to_inch * dpi)
692
+ target_height = int(height_mm * mm_to_inch * dpi)
693
+
694
+ # Resize panel to fit
695
+ panel_img = panel_img.resize((target_width, target_height), Image.Resampling.LANCZOS)
696
+
697
+ # Convert to RGB if necessary (for transparent PNGs)
698
+ if panel_img.mode == "RGBA":
699
+ # Create white background
700
+ bg = Image.new("RGB", panel_img.size, background)
701
+ bg.paste(panel_img, mask=panel_img.split()[3])
702
+ panel_img = bg
703
+ elif panel_img.mode != "RGB":
704
+ panel_img = panel_img.convert("RGB")
705
+
706
+ # Paste onto canvas
707
+ canvas.paste(panel_img, (x_px, y_px))
708
+
709
+ # Save composed figure
710
+ png_path = exports_dir / f"{basename}.png"
711
+ canvas.save(png_path, "PNG", dpi=(dpi, dpi))
712
+
713
+ # Also save as SVG (embed PNG in SVG for now)
714
+ svg_path = exports_dir / f"{basename}.svg"
715
+ svg_width_in = fig_width_mm * mm_to_inch
716
+ svg_height_in = fig_height_mm * mm_to_inch
717
+
718
+ # Create simple SVG wrapper with embedded image
719
+ import base64
720
+ with open(png_path, "rb") as f:
721
+ png_b64 = base64.b64encode(f.read()).decode("utf-8")
722
+
723
+ svg_content = f'''<?xml version="1.0" encoding="UTF-8"?>
724
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
725
+ width="{fig_width_px}" height="{fig_height_px}"
726
+ viewBox="0 0 {fig_width_px} {fig_height_px}">
727
+ <image width="{fig_width_px}" height="{fig_height_px}"
728
+ xlink:href="data:image/png;base64,{png_b64}"/>
729
+ </svg>'''
730
+
731
+ with open(svg_path, "w") as f:
732
+ f.write(svg_content)
733
+
734
+
735
+ def _generate_figz_geometry_cache(dir_path: Path, spec: Dict, basename: str) -> None:
736
+ """Generate figure-level geometry cache combining all panel geometries.
737
+
738
+ Creates:
739
+ cache/geometry_px.json - Combined geometry for all panels
740
+ cache/render_manifest.json - Figure-level render metadata
741
+
742
+ Args:
743
+ dir_path: Bundle directory path.
744
+ spec: Bundle specification.
745
+ basename: Base filename for bundle files.
746
+ """
747
+ from datetime import datetime
748
+
749
+ cache_dir = dir_path / "cache"
750
+ cache_dir.mkdir(parents=True, exist_ok=True)
751
+
752
+ # Collect geometry from all panel bundles
753
+ combined_geometry = {
754
+ "figure_id": basename,
755
+ "panels": {},
756
+ "generated_at": datetime.now().isoformat(),
757
+ }
758
+
759
+ # Find all panel directories
760
+ panel_dirs = sorted(dir_path.glob("*.pltz.d"))
761
+
762
+ for panel_dir in panel_dirs:
763
+ panel_id = panel_dir.stem.replace(".pltz", "")
764
+
765
+ # Load panel geometry
766
+ panel_geometry_path = panel_dir / "cache" / "geometry_px.json"
767
+ if panel_geometry_path.exists():
768
+ with open(panel_geometry_path, "r") as f:
769
+ panel_geometry = json.load(f)
770
+ combined_geometry["panels"][panel_id] = panel_geometry
771
+
772
+ # Add panel positions from spec
773
+ panels_spec = spec.get("panels", [])
774
+ for panel in panels_spec:
775
+ panel_id = panel.get("id")
776
+ if panel_id and panel_id in combined_geometry["panels"]:
777
+ combined_geometry["panels"][panel_id]["position_mm"] = panel.get("position", {})
778
+ combined_geometry["panels"][panel_id]["size_mm"] = panel.get("size", {})
779
+
780
+ # Save combined geometry
781
+ geometry_path = cache_dir / "geometry_px.json"
782
+ with open(geometry_path, "w") as f:
783
+ json.dump(combined_geometry, f, indent=2)
784
+
785
+ # Generate render manifest
786
+ figure_styles = spec.get("figure", {}).get("styles", {})
787
+ size = figure_styles.get("size", {})
788
+
789
+ manifest = {
790
+ "figure_id": basename,
791
+ "generated_at": datetime.now().isoformat(),
792
+ "size_mm": [size.get("width_mm", 0), size.get("height_mm", 0)],
793
+ "panels_count": len(panel_dirs),
794
+ "schema": spec.get("schema", {}),
795
+ }
796
+
797
+ manifest_path = cache_dir / "render_manifest.json"
798
+ with open(manifest_path, "w") as f:
799
+ json.dump(manifest, f, indent=2)
800
+
801
+
802
+ def _embed_metadata_in_export(
803
+ file_path: Path, spec: Dict[str, Any], fmt: str
804
+ ) -> None:
805
+ """Embed bundle spec metadata into exported image files."""
806
+ from scitex.io._metadata import embed_metadata
807
+
808
+ embed_data = {
809
+ "scitex_bundle": True,
810
+ "schema": spec.get("schema", {}),
811
+ }
812
+
813
+ for key in ["figure", "panels", "notations"]:
814
+ if key in spec:
815
+ embed_data[key] = spec[key]
816
+
817
+ if fmt in ("png", "pdf"):
818
+ embed_metadata(str(file_path), embed_data)
819
+
820
+
821
+ def _generate_figz_readme(
822
+ dir_path: Path, spec: Dict, data: Dict, basename: str
823
+ ) -> None:
824
+ """Generate a dynamic README.md for figz bundle.
825
+
826
+ Args:
827
+ dir_path: Bundle directory path.
828
+ spec: Bundle specification.
829
+ data: Bundle data dictionary.
830
+ basename: Base filename for bundle files.
831
+ """
832
+ from datetime import datetime
833
+
834
+ # Extract figure info
835
+ figure = spec.get("figure", {})
836
+ title = figure.get("title", basename)
837
+ caption = figure.get("caption", "")
838
+
839
+ # Load style from style.json if exists, else from spec.figure.styles
840
+ style_file = dir_path / "style.json"
841
+ if style_file.exists():
842
+ with open(style_file, "r") as f:
843
+ style = json.load(f)
844
+ size = style.get("size", {})
845
+ background = style.get("background", "#ffffff")
846
+ else:
847
+ styles = figure.get("styles", {})
848
+ size = styles.get("size", {})
849
+ background = styles.get("background", "#ffffff")
850
+
851
+ width_mm = size.get("width_mm", 0)
852
+ height_mm = size.get("height_mm", 0)
853
+
854
+ # Count panels
855
+ panels = spec.get("panels", [])
856
+ n_panels = len(panels)
857
+
858
+ # Find panel directories
859
+ panel_dirs = sorted(dir_path.glob("*.pltz.d"))
860
+
861
+ # Build panel table
862
+ panel_rows = ""
863
+ for panel in panels:
864
+ panel_id = panel.get("id", "?")
865
+ label = panel.get("label", panel_id)
866
+ plot_ref = panel.get("plot", "")
867
+ pos = panel.get("position", {})
868
+ panel_size = panel.get("size", {})
869
+ panel_rows += f"| {label} | {plot_ref} | ({pos.get('x_mm', 0)}, {pos.get('y_mm', 0)}) | {panel_size.get('width_mm', 0)} × {panel_size.get('height_mm', 0)} mm |\n"
870
+
871
+ # Build panel directory list
872
+ panel_dir_list = ""
873
+ for pd in panel_dirs:
874
+ panel_dir_list += f"│ ├── {pd.name}/\n"
875
+
876
+ readme_content = f"""# {basename}.figz.d
877
+
878
+ > SciTeX Figure Bundle - Auto-generated README
879
+
880
+ ## Overview
881
+
882
+ ![Figure Overview](exports/{basename}_overview.png)
883
+
884
+ ## Bundle Structure
885
+
886
+ ```
887
+ {basename}.figz.d/
888
+ ├── spec.json # Figure specification (semantic: what to draw)
889
+ ├── style.json # Figure style (appearance: how it looks)
890
+ ├── {basename}.json # Combined spec+style (legacy compatibility)
891
+ ├── exports/ # Figure-level exports
892
+ │ ├── {basename}.png # Rendered figure (raster)
893
+ │ ├── {basename}.svg # Rendered figure (vector)
894
+ │ └── {basename}_overview.png # Visual summary with hitmaps
895
+ ├── cache/ # Figure-level cache (regenerable)
896
+ │ ├── geometry_px.json # Combined geometry for all panels
897
+ │ └── render_manifest.json # Render metadata
898
+ {panel_dir_list}└── README.md # This file
899
+ ```
900
+
901
+ ## Figure Information
902
+
903
+ | Property | Value |
904
+ |----------|-------|
905
+ | Title | {title or '(none)'} |
906
+ | Panels | {n_panels} |
907
+ | Size | {width_mm:.1f} × {height_mm:.1f} mm |
908
+ | Background | `{background}` |
909
+
910
+ {f"**Caption**: {caption}" if caption else ""}
911
+
912
+ ## Panel Layout
913
+
914
+ | Label | Plot Bundle | Position (x, y) | Size |
915
+ |-------|-------------|-----------------|------|
916
+ {panel_rows}
917
+
918
+ ## Nested Bundles
919
+
920
+ Each panel is stored as a separate `.pltz.d` bundle containing:
921
+ - `spec.json` - What to plot (data, axes, traces)
922
+ - `style.json` - How it looks (colors, fonts, theme)
923
+ - `exports/` - Rendered images (PNG, SVG, hitmap)
924
+ - `cache/` - Computed geometry (regenerable)
925
+
926
+ ## Usage
927
+
928
+ ### Python
929
+
930
+ ```python
931
+ import scitex as stx
932
+
933
+ # Load the figure bundle
934
+ bundle = stx.load("{dir_path}")
935
+
936
+ # Access components
937
+ spec = bundle["spec"] # Figure layout
938
+ plots = bundle["plots"] # Dict of panel bundles
939
+
940
+ # Access specific panel
941
+ panel_a = plots["A"] # Get panel A's pltz bundle
942
+ ```
943
+
944
+ ### Editing
945
+
946
+ Edit `spec.json` to change semantic content:
947
+ - Panel positions and sizes
948
+ - Figure title and caption
949
+ - Panel layout
950
+
951
+ Edit `style.json` to change appearance:
952
+ - Figure size (width_mm, height_mm)
953
+ - Background color
954
+ - Panel label styling
955
+ - Theme (light/dark)
956
+
957
+ Edit individual `*.pltz.d/spec.json` and `*.pltz.d/style.json` to change:
958
+ - Plot data and axes (spec.json)
959
+ - Trace specifications (spec.json)
960
+ - Colors, fonts, theme (style.json)
961
+
962
+ ---
963
+
964
+ *Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*
965
+ *Schema: {spec.get("schema", {}).get("name", "scitex.fig.figure")} v{spec.get("schema", {}).get("version", "1.0.0")}*
966
+ """
967
+
968
+ readme_path = dir_path / "README.md"
969
+ with open(readme_path, "w") as f:
970
+ f.write(readme_content)
971
+
972
+
973
+ # EOF