plexora 0.0.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 (248) hide show
  1. plexora/__init__.py +129 -0
  2. plexora/__main__.py +15 -0
  3. plexora/_url.py +105 -0
  4. plexora/api/__init__.py +55 -0
  5. plexora/api/dataset.py +398 -0
  6. plexora/api/http.py +19 -0
  7. plexora/api/plugin.py +541 -0
  8. plexora/api/store.py +221 -0
  9. plexora/cli.py +777 -0
  10. plexora/client/dist/354_bundle.js +2 -0
  11. plexora/client/dist/354_bundle.js.LICENSE.txt +20 -0
  12. plexora/client/dist/418_bundle.js +2 -0
  13. plexora/client/dist/418_bundle.js.LICENSE.txt +1 -0
  14. plexora/client/dist/693b935cb1f907814be9c6199a68c217.svg +19 -0
  15. plexora/client/dist/713c134ff47cd328a5b711526f151082.svg +18 -0
  16. plexora/client/dist/770_bundle.js +1 -0
  17. plexora/client/dist/ff743f408972e0e96ca9153ed5ae4c83.svg +23 -0
  18. plexora/client/dist/vendor_bundle.js +2 -0
  19. plexora/client/dist/vendor_bundle.js.LICENSE.txt +308 -0
  20. plexora/client/external/openseadragon-bin-2.4.0/LICENSE.txt +28 -0
  21. plexora/client/external/openseadragon-bin-2.4.0/canvas-overlay-hd.js +140 -0
  22. plexora/client/external/openseadragon-bin-2.4.0/changelog.txt +501 -0
  23. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/.gitattributes +17 -0
  24. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/LICENSE.txt +116 -0
  25. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/Toolbar.png +0 -0
  26. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/fullpage.psd +0 -0
  27. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/home.psd +0 -0
  28. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/next.psd +0 -0
  29. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/previous.psd +0 -0
  30. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateleft.psd +0 -0
  31. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateright.psd +0 -0
  32. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/zoom.psd +0 -0
  33. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/README.md +9 -0
  34. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_grouphover.png +0 -0
  35. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_hover.png +0 -0
  36. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_pressed.png +0 -0
  37. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_rest.png +0 -0
  38. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_grouphover.png +0 -0
  39. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_hover.png +0 -0
  40. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_pressed.png +0 -0
  41. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_rest.png +0 -0
  42. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_grouphover.png +0 -0
  43. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_hover.png +0 -0
  44. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_pressed.png +0 -0
  45. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_rest.png +0 -0
  46. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_grouphover.png +0 -0
  47. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_hover.png +0 -0
  48. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_pressed.png +0 -0
  49. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_rest.png +0 -0
  50. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_grouphover.png +0 -0
  51. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_hover.png +0 -0
  52. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_pressed.png +0 -0
  53. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_rest.png +0 -0
  54. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_grouphover.png +0 -0
  55. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_hover.png +0 -0
  56. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_pressed.png +0 -0
  57. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_rest.png +0 -0
  58. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_grouphover.png +0 -0
  59. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_hover.png +0 -0
  60. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_pressed.png +0 -0
  61. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_rest.png +0 -0
  62. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_grouphover.png +0 -0
  63. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_hover.png +0 -0
  64. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_pressed.png +0 -0
  65. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_rest.png +0 -0
  66. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-scalebar.js +562 -0
  67. plexora/client/src/css/import.css +474 -0
  68. plexora/client/src/css/main.css +903 -0
  69. plexora/client/src/css/openProject.css +363 -0
  70. plexora/client/src/css/quickView.css +166 -0
  71. plexora/client/src/css/tokens.css +51 -0
  72. plexora/client/src/css/viewer.css +1655 -0
  73. plexora/client/src/img/apple-touch-icon.png +0 -0
  74. plexora/client/src/img/favicon.ico +0 -0
  75. plexora/client/src/img/logo.ai +8892 -33
  76. plexora/client/src/img/logo.svg +1 -0
  77. plexora/client/src/img/logo_with_text.ai +9011 -34
  78. plexora/client/src/img/logo_with_text.svg +1 -0
  79. plexora/client/src/js/main.js +704 -0
  80. plexora/client/src/js/pluginRegistry.js +108 -0
  81. plexora/client/src/js/services/appStatus.js +286 -0
  82. plexora/client/src/js/services/browsePicker.js +73 -0
  83. plexora/client/src/js/services/dataLayer.js +400 -0
  84. plexora/client/src/js/services/datasetContext.js +143 -0
  85. plexora/client/src/js/services/glRenderer.js +143 -0
  86. plexora/client/src/js/services/importFormValidation.js +257 -0
  87. plexora/client/src/js/services/numericData.js +100 -0
  88. plexora/client/src/js/services/passVariablesToFrontend.js +20 -0
  89. plexora/client/src/js/services/simpleEventHandler.js +25 -0
  90. plexora/client/src/js/vendor.js +29 -0
  91. plexora/client/src/js/views/channelList.js +663 -0
  92. plexora/client/src/js/views/colorSwatchPicker.js +202 -0
  93. plexora/client/src/js/views/columnClassifier.js +146 -0
  94. plexora/client/src/js/views/coordinateField.js +133 -0
  95. plexora/client/src/js/views/dataSourceField.js +276 -0
  96. plexora/client/src/js/views/imageViewer.js +3306 -0
  97. plexora/client/src/js/views/miniMap.js +787 -0
  98. plexora/client/src/js/views/navbarControls.js +178 -0
  99. plexora/client/src/js/views/openProjectPage.js +239 -0
  100. plexora/client/src/js/views/projectEdit.js +335 -0
  101. plexora/client/src/js/views/quickViewLanding.js +136 -0
  102. plexora/client/src/js/views/rainbow.js +326 -0
  103. plexora/client/src/js/views/requirementsModal.js +572 -0
  104. plexora/client/src/js/views/rgbImageViewer.js +115 -0
  105. plexora/client/src/js/views/searchableSelect.js +411 -0
  106. plexora/client/src/js/views/segmentationProgress.js +122 -0
  107. plexora/client/src/js/views/toolLoader.js +747 -0
  108. plexora/client/src/js/views/viewerControls.js +689 -0
  109. plexora/client/src/js/views/viewerManager.js +434 -0
  110. plexora/client/src/js/views/viewerSidebar.js +1117 -0
  111. plexora/client/src/js/workers/tileDecoder.js +130 -0
  112. plexora/client/src/shaders/frag.glsl +528 -0
  113. plexora/client/src/shaders/vert.glsl +12 -0
  114. plexora/client/templates/base.html +212 -0
  115. plexora/client/templates/index.html +238 -0
  116. plexora/client/templates/open_project.html +96 -0
  117. plexora/client/templates/project_columns.html +86 -0
  118. plexora/client/templates/project_edit.html +196 -0
  119. plexora/client/templates/upload.html +118 -0
  120. plexora/connect.py +528 -0
  121. plexora/datasource.py +782 -0
  122. plexora/jupyter.py +406 -0
  123. plexora/notebook_env.py +232 -0
  124. plexora/paths.py +486 -0
  125. plexora/plugins/__init__.py +12 -0
  126. plexora/plugins/cell_explorer/__init__.py +100 -0
  127. plexora/plugins/cell_explorer/server/__init__.py +11 -0
  128. plexora/plugins/cell_explorer/server/routes.py +167 -0
  129. plexora/plugins/cell_explorer/server/state.py +281 -0
  130. plexora/plugins/cell_explorer/server/values.py +145 -0
  131. plexora/plugins/cell_explorer/server/variables.py +367 -0
  132. plexora/plugins/cell_explorer/static/cellExplorerApi.js +158 -0
  133. plexora/plugins/cell_explorer/static/cellExplorerColors.js +316 -0
  134. plexora/plugins/cell_explorer/static/cellExplorerContinuous.js +403 -0
  135. plexora/plugins/cell_explorer/static/cellExplorerFigureBridge.js +133 -0
  136. plexora/plugins/cell_explorer/static/cellExplorerLegend.js +188 -0
  137. plexora/plugins/cell_explorer/static/cellExplorerRoiBridge.js +822 -0
  138. plexora/plugins/cell_explorer/static/cellExplorerSidebarController.js +826 -0
  139. plexora/plugins/cell_explorer/static/cellExplorerState.js +372 -0
  140. plexora/plugins/cell_explorer/static/cell_explorer.css +752 -0
  141. plexora/plugins/cell_explorer/templates/cell_explorer/panel.html +137 -0
  142. plexora/plugins/figure_builder/__init__.py +125 -0
  143. plexora/plugins/figure_builder/server/__init__.py +7 -0
  144. plexora/plugins/figure_builder/server/compose.py +365 -0
  145. plexora/plugins/figure_builder/server/export.py +784 -0
  146. plexora/plugins/figure_builder/server/export_jobs.py +148 -0
  147. plexora/plugins/figure_builder/server/operations.py +556 -0
  148. plexora/plugins/figure_builder/server/pixels.py +142 -0
  149. plexora/plugins/figure_builder/server/provenance.py +174 -0
  150. plexora/plugins/figure_builder/server/render.py +278 -0
  151. plexora/plugins/figure_builder/server/repository.py +701 -0
  152. plexora/plugins/figure_builder/server/routes.py +594 -0
  153. plexora/plugins/figure_builder/server/schema.py +1023 -0
  154. plexora/plugins/figure_builder/server/sources.py +120 -0
  155. plexora/plugins/figure_builder/server/textmetrics.py +155 -0
  156. plexora/plugins/figure_builder/static/figureActions.js +363 -0
  157. plexora/plugins/figure_builder/static/figureBuilderApi.js +255 -0
  158. plexora/plugins/figure_builder/static/figureCanvas.js +2362 -0
  159. plexora/plugins/figure_builder/static/figureCaptureBoxes.js +369 -0
  160. plexora/plugins/figure_builder/static/figureCaptureDock.js +466 -0
  161. plexora/plugins/figure_builder/static/figureCaptureTool.js +1034 -0
  162. plexora/plugins/figure_builder/static/figureConfirm.js +136 -0
  163. plexora/plugins/figure_builder/static/figureContextBar.js +1193 -0
  164. plexora/plugins/figure_builder/static/figureContextMenu.js +253 -0
  165. plexora/plugins/figure_builder/static/figureDocumentState.js +275 -0
  166. plexora/plugins/figure_builder/static/figureExportUi.js +174 -0
  167. plexora/plugins/figure_builder/static/figureLibrary.js +247 -0
  168. plexora/plugins/figure_builder/static/figureQuickEdit.js +628 -0
  169. plexora/plugins/figure_builder/static/figureRichText.js +634 -0
  170. plexora/plugins/figure_builder/static/figureSceneSnapshot.js +361 -0
  171. plexora/plugins/figure_builder/static/figureSchema.js +218 -0
  172. plexora/plugins/figure_builder/static/figureSidebarController.js +1159 -0
  173. plexora/plugins/figure_builder/static/figureTextEditor.js +503 -0
  174. plexora/plugins/figure_builder/static/figureTextPanel.js +491 -0
  175. plexora/plugins/figure_builder/static/figureViewOptions.js +273 -0
  176. plexora/plugins/figure_builder/static/figureWorkspace.js +2030 -0
  177. plexora/plugins/figure_builder/static/figure_builder.css +3292 -0
  178. plexora/plugins/figure_builder/templates/figure_builder/library.html +74 -0
  179. plexora/plugins/figure_builder/templates/figure_builder/workspace.html +27 -0
  180. plexora/plugins/figure_builder/templates/figure_builder/workspace_body.html +458 -0
  181. plexora/plugins/gating/__init__.py +76 -0
  182. plexora/plugins/gating/server/__init__.py +0 -0
  183. plexora/plugins/gating/server/anndata_gates.py +367 -0
  184. plexora/plugins/gating/server/database.py +16 -0
  185. plexora/plugins/gating/server/model.py +320 -0
  186. plexora/plugins/gating/server/routes.py +235 -0
  187. plexora/plugins/gating/static/csvGatingList.js +947 -0
  188. plexora/plugins/gating/static/gating.css +289 -0
  189. plexora/plugins/gating/static/gatingApi.js +221 -0
  190. plexora/plugins/gating/static/gatingSidebarController.js +513 -0
  191. plexora/plugins/gating/templates/gating/legacy.html +26 -0
  192. plexora/plugins/gating/templates/gating/panel.html +69 -0
  193. plexora/plugins/roi/__init__.py +95 -0
  194. plexora/plugins/roi/server/__init__.py +8 -0
  195. plexora/plugins/roi/server/adapters.py +655 -0
  196. plexora/plugins/roi/server/geojson.py +267 -0
  197. plexora/plugins/roi/server/geometry.py +181 -0
  198. plexora/plugins/roi/server/mapping.py +169 -0
  199. plexora/plugins/roi/server/operations.py +293 -0
  200. plexora/plugins/roi/server/repository.py +215 -0
  201. plexora/plugins/roi/server/routes.py +409 -0
  202. plexora/plugins/roi/server/schema.py +323 -0
  203. plexora/plugins/roi/static/roi.css +583 -0
  204. plexora/plugins/roi/static/roiApi.js +177 -0
  205. plexora/plugins/roi/static/roiFigureBridge.js +107 -0
  206. plexora/plugins/roi/static/roiGeometry.js +350 -0
  207. plexora/plugins/roi/static/roiRenderer.js +278 -0
  208. plexora/plugins/roi/static/roiSidebarController.js +1038 -0
  209. plexora/plugins/roi/static/roiState.js +518 -0
  210. plexora/plugins/roi/static/roiTools.js +993 -0
  211. plexora/plugins/roi/templates/roi/panel.html +240 -0
  212. plexora/proxy.py +57 -0
  213. plexora/server/models/adapters/__init__.py +94 -0
  214. plexora/server/models/adapters/anndata_adapter.py +388 -0
  215. plexora/server/models/adapters/base.py +93 -0
  216. plexora/server/models/adapters/classify.py +192 -0
  217. plexora/server/models/adapters/csv_adapter.py +105 -0
  218. plexora/server/models/adapters/inspection.py +265 -0
  219. plexora/server/models/adapters/spatialdata_adapter.py +248 -0
  220. plexora/server/models/centroid_tiles.py +305 -0
  221. plexora/server/models/data_model.py +1659 -0
  222. plexora/server/models/database_model.py +169 -0
  223. plexora/server/models/project.py +1270 -0
  224. plexora/server/plugins.py +199 -0
  225. plexora/server/routes/browse_routes.py +41 -0
  226. plexora/server/routes/data_routes.py +294 -0
  227. plexora/server/routes/import_routes.py +603 -0
  228. plexora/server/routes/page_routes.py +139 -0
  229. plexora/server/routes/project_routes.py +438 -0
  230. plexora/server/routes/quick_view_routes.py +62 -0
  231. plexora/server/routes/system_routes.py +39 -0
  232. plexora/server/routes/tool_routes.py +418 -0
  233. plexora/server/utils/addHEColumns.py +11 -0
  234. plexora/server/utils/fast_png.py +97 -0
  235. plexora/server/utils/fullConversion.py +50 -0
  236. plexora/server/utils/native_dialog.py +168 -0
  237. plexora/server/utils/pre_normalization.py +47 -0
  238. plexora/server/utils/segmentation_pyramid.py +598 -0
  239. plexora/server/utils/smallestenclosingcircle.py +126 -0
  240. plexora/server/utils/tiffsurgeon.py +372 -0
  241. plexora/server_cli.py +48 -0
  242. plexora/test.py +0 -0
  243. plexora-0.0.1.dist-info/METADATA +341 -0
  244. plexora-0.0.1.dist-info/RECORD +248 -0
  245. plexora-0.0.1.dist-info/WHEEL +5 -0
  246. plexora-0.0.1.dist-info/entry_points.txt +12 -0
  247. plexora-0.0.1.dist-info/licenses/LICENSE +207 -0
  248. plexora-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1023 @@
1
+ """What a figure IS, and what a valid piece of one looks like.
2
+
3
+ One versioned JSON document per figure holds the whole thing: the sources it
4
+ draws on, the pages, the panels and their captured scenes, the annotations, and
5
+ the revision the conflict check turns on.
6
+
7
+ {
8
+ "schema_version": 1,
9
+ "figure_id": "fig_a1b2c3d4e5f6",
10
+ "revision": 42,
11
+ "title": "Figure 1",
12
+ "created_at": "...", "updated_at": "...",
13
+ "sources": {"src_1": {...}},
14
+ "pages": [{"page_id": "pg_1", ...}],
15
+ "panels": {"pnl_1": {...}},
16
+ "annotations": {"ann_1": {...}},
17
+ "link_groups": {"grp_1": {...}},
18
+ "groups": {"grp_2": {...}},
19
+ "settings": {"dpi_default": 300, "style": {...}}
20
+ }
21
+
22
+ Four structural decisions worth stating, because all four are cheap now and
23
+ expensive later:
24
+
25
+ **No image data, ever.** A source is a reference plus a fingerprint. The heavy
26
+ thing a figure points at -- a 90,000 x 76,000 pyramid -- stays where it is, and
27
+ a figure stays a few hundred kilobytes however many panels it holds.
28
+
29
+ **A page does not list its panels.** Membership is `panel.placement.page_id` and
30
+ nothing else. A page carrying `panel_ids` as well would be a second answer to
31
+ the same question, and the two answers drift the first time an operation
32
+ forgets one of them -- producing a panel that is on a page and not on it. Order
33
+ within a page is `placement.z`. A panel with `placement: null` is in the tray:
34
+ captured, kept, not laid out.
35
+
36
+ **Geometry is in two different units on purpose.** Everything about the SOURCE
37
+ -- `scene.viewport` -- is full-resolution image pixels. Everything about the
38
+ PAGE -- placement, annotations, page size -- is millimetres. Mixing them is how
39
+ a figure ends up rendering at the resolution of the screen it was built on. The
40
+ two never meet until export, where mm x DPI decides how many source pixels to
41
+ read.
42
+
43
+ **Physical scale is recorded, never inferred.** `source.pixel_size` is what the
44
+ OME metadata said at capture time, or null. A null disables scale bars and says
45
+ so; nothing anywhere multiplies a guess.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import re
51
+
52
+ from plexora.plugins.figure_builder.server import textmetrics
53
+
54
+ SCHEMA_VERSION = 1
55
+
56
+ #: The snapshot inside a panel is versioned separately from the document that
57
+ #: holds it. They change for different reasons -- a new page property is not a
58
+ #: new way of describing a viewer -- and a figure written before a snapshot
59
+ #: field existed must still open.
60
+ SNAPSHOT_VERSION = 1
61
+
62
+
63
+ class UnreadableFigure(Exception):
64
+ """This figure cannot be read by this build.
65
+
66
+ Raised rather than shrugged off. Reading a newer document with today's rules
67
+ means quietly dropping whatever the newer schema added, and then writing
68
+ that loss back on the next autosave -- which turns "this build is too old"
69
+ into "your panels are gone", with nothing on screen that says so.
70
+ """
71
+
72
+
73
+ #: Ids are generated server-side for figures and client-side for everything
74
+ #: inside one (so a panel appears the instant it is captured rather than after
75
+ #: a round trip). Validated rather than trusted: they end up in DOM attributes,
76
+ #: selectors and file paths.
77
+ ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$")
78
+
79
+ #: Figure ids additionally become a DIRECTORY NAME under data_path/.figures/,
80
+ #: so they are held to a much narrower rule than ids that only live inside the
81
+ #: document. No dots, no colons, no dashes -- nothing that could be a path, a
82
+ #: drive letter or an extension on any platform.
83
+ FIGURE_ID_PATTERN = re.compile(r"^fig_[a-z0-9]{6,32}$")
84
+
85
+ MAX_TITLE_LENGTH = 200
86
+ MAX_TEXT_LENGTH = 4_000
87
+ _CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
88
+
89
+ #: Control characters that may not appear INSIDE a run. A run is one span of
90
+ #: uniformly styled text on one line, so a newline in it would be a line break
91
+ #: the line list does not know about -- `rich.lines` is the only place a break
92
+ #: is recorded, and two answers is one too many.
93
+ _RUN_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
94
+
95
+ #: Ceilings on one text annotation's structure. The character budget is still
96
+ #: MAX_TEXT_LENGTH and these sit under it: a paste can only be so long, but it
97
+ #: could still arrive as four thousand one-character runs from a word processor
98
+ #: that marks up every letter. Coalescing removes most of that; these stop the
99
+ #: rest.
100
+ MAX_TEXT_LINES = 200
101
+ MAX_RUNS_PER_LINE = 100
102
+ MAX_TEXT_RUNS = 500
103
+
104
+ COLOR_PATTERN = re.compile(r"^#[0-9a-fA-F]{6}$")
105
+
106
+ #: Ceilings on one figure. Generous -- these exist to stop a runaway client, not
107
+ #: to tell anyone how many panels their figure has.
108
+ MAX_PAGES = 200
109
+ MAX_PANELS = 2_000
110
+ MAX_ANNOTATIONS = 5_000
111
+ MAX_SOURCES = 200
112
+ MAX_GROUPS = 1_000
113
+
114
+ #: Page sizes in millimetres. Journal column widths are deliberately absent:
115
+ #: they change, they differ per publisher, and a hard-coded list that is wrong
116
+ #: is worse than no list. `custom` is any w/h the user types.
117
+ PAGE_PRESETS = {
118
+ "a4": (210.0, 297.0),
119
+ "letter": (215.9, 279.4),
120
+ "square": (200.0, 200.0),
121
+ }
122
+ DEFAULT_PRESET = "a4"
123
+
124
+ #: Smallest and largest page a figure may declare, in mm. The lower bound stops
125
+ #: a divide-by-a-hair when panel geometry is scaled to the page; the upper one
126
+ #: stops a typo asking the export renderer for a 40-metre canvas.
127
+ MIN_PAGE_MM = 10.0
128
+ MAX_PAGE_MM = 2_000.0
129
+
130
+ ANNOTATION_TYPES = ("text", "arrow", "line", "rect", "ellipse")
131
+
132
+ #: A page's background is a hex colour or this. See page_background().
133
+ TRANSPARENT = "transparent"
134
+
135
+ SOURCE_KINDS = ("plexora_project", "imported_asset")
136
+
137
+ #: How a source compares against the project it names, recomputed on open.
138
+ #: `changed` never rerenders anything on its own -- see repository.py.
139
+ SOURCE_STATUSES = ("ok", "changed", "missing", "unknown")
140
+
141
+ DEFAULT_TITLE = "Untitled Figure"
142
+
143
+
144
+ # -- documents ----------------------------------------------------------
145
+
146
+
147
+ def new_document(figure_id, title=None, created_at=None):
148
+ """A figure nobody has put anything in yet.
149
+
150
+ One empty A4 page, because a figure with no pages has nowhere to drop the
151
+ first captured panel and "add a page" is not a decision anyone wants to make
152
+ before they have seen a panel.
153
+ """
154
+ stamp = created_at or ""
155
+ return {
156
+ "schema_version": SCHEMA_VERSION,
157
+ "figure_id": figure_id,
158
+ "revision": 0,
159
+ "title": clean_text(title) or DEFAULT_TITLE,
160
+ "created_at": stamp,
161
+ "updated_at": stamp,
162
+ "sources": {},
163
+ "pages": [new_page("pg_1", name="Page 1")],
164
+ "panels": {},
165
+ "annotations": {},
166
+ "link_groups": {},
167
+ "groups": {},
168
+ "settings": default_settings(),
169
+ }
170
+
171
+
172
+ def normalize_document(raw, figure_id=None):
173
+ """Coerce a loaded document into the current shape.
174
+
175
+ Tolerant on the way in and strict on the way out, like ROI's: an older
176
+ document is upgraded, and an entry that cannot be understood is dropped
177
+ rather than allowed to half-load. Losing one malformed panel is better than
178
+ refusing to open a figure that represents a day of work -- but a document
179
+ from the FUTURE is refused outright, because there is no safe way to guess
180
+ what was dropped.
181
+ """
182
+ if not isinstance(raw, dict):
183
+ raise UnreadableFigure("this figure's document is not an object")
184
+
185
+ version = raw.get("schema_version")
186
+ if isinstance(version, int) and not isinstance(version, bool) and version > SCHEMA_VERSION:
187
+ raise UnreadableFigure(
188
+ f"this figure was written by a newer version of Plexora "
189
+ f"(schema {version}, this build reads {SCHEMA_VERSION})"
190
+ )
191
+
192
+ sources = {}
193
+ for key, entry in (raw.get("sources") or {}).items():
194
+ try:
195
+ source = normalize_source({**entry, "source_id": key} if isinstance(entry, dict) else {})
196
+ except ValueError:
197
+ continue
198
+ sources[source["source_id"]] = source
199
+
200
+ pages = []
201
+ seen_pages = set()
202
+ for entry in raw.get("pages") or []:
203
+ if not isinstance(entry, dict):
204
+ continue
205
+ try:
206
+ page = normalize_page(entry)
207
+ except ValueError:
208
+ continue
209
+ if page["page_id"] in seen_pages:
210
+ continue
211
+ seen_pages.add(page["page_id"])
212
+ pages.append(page)
213
+ if not pages:
214
+ pages = [new_page("pg_1", name="Page 1")]
215
+ seen_pages = {"pg_1"}
216
+
217
+ panels = {}
218
+ for key, entry in (raw.get("panels") or {}).items():
219
+ try:
220
+ panel = normalize_panel({**entry, "panel_id": key} if isinstance(entry, dict) else {})
221
+ except ValueError:
222
+ continue
223
+ # A panel whose page was dropped is not deleted -- it goes back to the
224
+ # tray, where the user can see it and decide. Silently discarding a
225
+ # captured scene because its page failed to parse is the one outcome
226
+ # nobody wants.
227
+ if panel["placement"] and panel["placement"]["page_id"] not in seen_pages:
228
+ panel["placement"] = None
229
+ panels[panel["panel_id"]] = panel
230
+
231
+ annotations = {}
232
+ for key, entry in (raw.get("annotations") or {}).items():
233
+ try:
234
+ annotation = normalize_annotation(
235
+ {**entry, "annotation_id": key} if isinstance(entry, dict) else {})
236
+ except ValueError:
237
+ continue
238
+ if annotation["page_id"] not in seen_pages:
239
+ continue
240
+ annotations[annotation["annotation_id"]] = annotation
241
+
242
+ groups = {}
243
+ for key, entry in (raw.get("link_groups") or {}).items():
244
+ try:
245
+ group = normalize_link_group(
246
+ {**entry, "group_id": key} if isinstance(entry, dict) else {})
247
+ except ValueError:
248
+ continue
249
+ group["panel_ids"] = [p for p in group["panel_ids"] if p in panels]
250
+ if len(group["panel_ids"]) < 2:
251
+ continue
252
+ groups[group["group_id"]] = group
253
+
254
+ # A panel's own link_group is advisory; the group's membership list is the
255
+ # rule. Reconciled here so nothing downstream has to handle a panel that
256
+ # names a group the group does not name back.
257
+ membership = {panel_id: gid for gid, g in groups.items() for panel_id in g["panel_ids"]}
258
+ for panel_id, panel in panels.items():
259
+ panel["link_group"] = membership.get(panel_id)
260
+
261
+ # Visual groups. A document written before this key existed simply has
262
+ # none, which is why the field is additive rather than a schema bump: an
263
+ # older Plexora reading a figure with groups loses the grouping and nothing
264
+ # else, and that is a legible outcome rather than a corrupted one.
265
+ #
266
+ # Membership lives ONLY here -- unlike link_groups, a member carries no
267
+ # back-reference. There is nowhere to put one on an annotation without
268
+ # adding a field to two shapes, and one answer to "what is grouped" cannot
269
+ # drift from itself.
270
+ visual_groups = {}
271
+ for key, entry in (raw.get("groups") or {}).items():
272
+ try:
273
+ group = normalize_group(
274
+ {**entry, "group_id": key} if isinstance(entry, dict) else {})
275
+ except ValueError:
276
+ continue
277
+ group["member_ids"] = [m for m in group["member_ids"]
278
+ if m in panels or m in annotations]
279
+ # A group of one selects one thing, which is what a click already does.
280
+ if len(group["member_ids"]) < 2:
281
+ continue
282
+ visual_groups[group["group_id"]] = group
283
+
284
+ # Nothing belongs to two groups. The first one wins rather than the last,
285
+ # so a reload is deterministic.
286
+ claimed = set()
287
+ for group_id in list(visual_groups):
288
+ members = [m for m in visual_groups[group_id]["member_ids"] if m not in claimed]
289
+ if len(members) < 2:
290
+ visual_groups.pop(group_id)
291
+ continue
292
+ visual_groups[group_id]["member_ids"] = members
293
+ claimed.update(members)
294
+
295
+ revision = raw.get("revision")
296
+ return {
297
+ "schema_version": SCHEMA_VERSION,
298
+ "figure_id": clean_id(raw.get("figure_id")) or figure_id or "",
299
+ "revision": 0 if isinstance(revision, bool) else max(0, as_int(revision, 0)),
300
+ "title": clean_text(raw.get("title")) or DEFAULT_TITLE,
301
+ "created_at": clean_text(raw.get("created_at")),
302
+ "updated_at": clean_text(raw.get("updated_at")),
303
+ "sources": sources,
304
+ "pages": pages,
305
+ "panels": panels,
306
+ "annotations": annotations,
307
+ "link_groups": groups,
308
+ "groups": visual_groups,
309
+ "settings": normalize_settings(raw.get("settings")),
310
+ }
311
+
312
+
313
+ def default_settings():
314
+ """Document-level defaults every object inherits and any object may override.
315
+
316
+ `dpi_default` is what the export dialog opens on, not a promise: whether the
317
+ source can actually supply that many pixels is checked at export time
318
+ against the pyramid, and reported rather than silently downscaled.
319
+ """
320
+ return {
321
+ "dpi_default": 300,
322
+ "style": {
323
+ "font_family": "Helvetica",
324
+ "font_size_pt": 8.0,
325
+ "label_size_pt": 10.0,
326
+ "title_size_pt": 9.0,
327
+ "gutter_mm": 3.0,
328
+ "line_width_pt": 0.75,
329
+ "text_color": "#000000",
330
+ "panel_background": "#000000",
331
+ },
332
+ "label_style": "A",
333
+ }
334
+
335
+
336
+ def normalize_settings(raw):
337
+ raw = raw if isinstance(raw, dict) else {}
338
+ defaults = default_settings()
339
+ style = {**defaults["style"]}
340
+ for key, value in (raw.get("style") or {}).items():
341
+ if key not in style:
342
+ continue
343
+ if key in ("text_color", "panel_background"):
344
+ style[key] = color(value, style[key])
345
+ elif key == "font_family":
346
+ style[key] = clean_text(value) or style[key]
347
+ else:
348
+ style[key] = as_float(value, style[key])
349
+ label_style = clean_text(raw.get("label_style")) or defaults["label_style"]
350
+ return {
351
+ "dpi_default": clamp(as_int(raw.get("dpi_default"), defaults["dpi_default"]), 72, 1200),
352
+ "style": style,
353
+ "label_style": label_style if label_style in ("A", "a", "A1") else defaults["label_style"],
354
+ }
355
+
356
+
357
+ # -- pages --------------------------------------------------------------
358
+
359
+
360
+ def new_page(page_id, name="", preset=DEFAULT_PRESET, orientation="portrait"):
361
+ width, height = PAGE_PRESETS.get(preset, PAGE_PRESETS[DEFAULT_PRESET])
362
+ if orientation == "landscape":
363
+ width, height = height, width
364
+ return {
365
+ "page_id": page_id,
366
+ "name": clean_text(name) or page_id,
367
+ "preset": preset if preset in PAGE_PRESETS else "custom",
368
+ "orientation": orientation if orientation in ("portrait", "landscape") else "portrait",
369
+ "size_mm": {"w": width, "h": height},
370
+ "margins_mm": {"top": 10.0, "right": 10.0, "bottom": 10.0, "left": 10.0},
371
+ "background": "#ffffff",
372
+ }
373
+
374
+
375
+ def normalize_page(raw):
376
+ size = raw.get("size_mm") if isinstance(raw.get("size_mm"), dict) else {}
377
+ margins = raw.get("margins_mm") if isinstance(raw.get("margins_mm"), dict) else {}
378
+ preset = clean_text(raw.get("preset")) or "custom"
379
+ orientation = clean_text(raw.get("orientation")) or "portrait"
380
+ fallback = new_page(validate_id(raw.get("page_id"), "page id"),
381
+ preset=preset, orientation=orientation)
382
+ return {
383
+ "page_id": fallback["page_id"],
384
+ "name": clean_text(raw.get("name")) or fallback["page_id"],
385
+ "preset": preset if preset in PAGE_PRESETS else "custom",
386
+ "orientation": fallback["orientation"],
387
+ "size_mm": {
388
+ "w": clamp(as_float(size.get("w"), fallback["size_mm"]["w"]), MIN_PAGE_MM, MAX_PAGE_MM),
389
+ "h": clamp(as_float(size.get("h"), fallback["size_mm"]["h"]), MIN_PAGE_MM, MAX_PAGE_MM),
390
+ },
391
+ "margins_mm": {
392
+ side: clamp(as_float(margins.get(side), 10.0), 0.0, MAX_PAGE_MM / 2)
393
+ for side in ("top", "right", "bottom", "left")
394
+ },
395
+ "background": page_background(raw.get("background")),
396
+ }
397
+
398
+
399
+ def page_background(value):
400
+ """A hex colour, or the literal "transparent".
401
+
402
+ Transparency is a page property rather than a colour because that is what
403
+ it is: a figure destined for a dark-background slide, or for a journal that
404
+ composites it onto its own paper, has no background rather than a white one.
405
+ Sentinel string rather than a null, so `page["background"]` is always a
406
+ string and every reader keeps one type.
407
+
408
+ Only PNG can honour it. PDF and TIFF are told to render white and the
409
+ export dialog says so -- silently flattening onto black, which is what an
410
+ unhandled alpha channel usually produces, would ruin a figure at the last
411
+ step.
412
+ """
413
+ if isinstance(value, str) and value.strip().lower() == TRANSPARENT:
414
+ return TRANSPARENT
415
+ return color(value, "#ffffff")
416
+
417
+ # -- sources ------------------------------------------------------------
418
+
419
+
420
+ def normalize_source(raw):
421
+ """One image a figure draws on, as a reference and a fingerprint.
422
+
423
+ `datasource` is the config.json key, which is the only stable image identity
424
+ Plexora has -- there is no separate image asset id to fall back on. The
425
+ fingerprint is what `changed` is decided by later: dimensions, the channel
426
+ keys, and whether a mask was there. Hashing the pixels was considered and
427
+ rejected; a fingerprint that takes four minutes to compute is one nobody
428
+ computes.
429
+ """
430
+ kind = clean_text(raw.get("kind")) or "plexora_project"
431
+ if kind not in SOURCE_KINDS:
432
+ raise ValueError(f"unknown source kind {kind!r}")
433
+
434
+ image = raw.get("image") if isinstance(raw.get("image"), dict) else {}
435
+ pixel_size = normalize_pixel_size(raw.get("pixel_size"))
436
+
437
+ status = clean_text(raw.get("status")) or "unknown"
438
+ return {
439
+ "source_id": validate_id(raw.get("source_id"), "source id"),
440
+ "kind": kind,
441
+ "datasource": clean_text(raw.get("datasource")),
442
+ "asset_id": clean_text(raw.get("asset_id")),
443
+ "display_name": clean_text(raw.get("display_name")),
444
+ "image": {
445
+ "width": as_int(image.get("width"), 0),
446
+ "height": as_int(image.get("height"), 0),
447
+ },
448
+ "pixel_size": pixel_size,
449
+ "channels": [
450
+ {"key": clean_text(c.get("key")),
451
+ "fullname_at_capture": clean_text(c.get("fullname_at_capture"))}
452
+ for c in raw.get("channels") or []
453
+ if isinstance(c, dict) and clean_text(c.get("key"))
454
+ ],
455
+ "fingerprint": normalize_fingerprint(raw.get("fingerprint")),
456
+ "status": status if status in SOURCE_STATUSES else "unknown",
457
+ }
458
+
459
+
460
+ def normalize_pixel_size(raw):
461
+ """Microns per pixel, or None -- never a default.
462
+
463
+ A missing calibration disables scale bars and says so on the panel. The
464
+ alternative, quietly assuming some conventional value, produces a figure
465
+ with a scale bar that is simply wrong and looks exactly like one that is
466
+ right.
467
+ """
468
+ if not isinstance(raw, dict):
469
+ return None
470
+ value = as_float(raw.get("value"), 0.0)
471
+ if value <= 0:
472
+ return None
473
+ return {
474
+ "value": value,
475
+ "unit": clean_text(raw.get("unit")) or "µm",
476
+ # Recorded because it changes what the number means: a value the user
477
+ # typed for an uncalibrated import is not the same evidence as one the
478
+ # file stated, and the provenance page says which.
479
+ "source": "manual" if clean_text(raw.get("source")) == "manual" else "metadata",
480
+ }
481
+
482
+
483
+ def normalize_fingerprint(raw):
484
+ raw = raw if isinstance(raw, dict) else {}
485
+ return {
486
+ "image_width": as_int(raw.get("image_width"), 0),
487
+ "image_height": as_int(raw.get("image_height"), 0),
488
+ "channel_keys": [clean_text(k) for k in raw.get("channel_keys") or [] if clean_text(k)],
489
+ "has_segmentation": bool(raw.get("has_segmentation", False)),
490
+ }
491
+
492
+
493
+ # -- panels -------------------------------------------------------------
494
+
495
+
496
+ def normalize_panel(raw):
497
+ placement = raw.get("placement")
498
+ label = raw.get("label") if isinstance(raw.get("label"), dict) else {}
499
+ scalebar = raw.get("scalebar") if isinstance(raw.get("scalebar"), dict) else {}
500
+ legend = raw.get("legend") if isinstance(raw.get("legend"), dict) else {}
501
+ derived = raw.get("derived_from") if isinstance(raw.get("derived_from"), dict) else None
502
+
503
+ return {
504
+ "panel_id": validate_id(raw.get("panel_id"), "panel id"),
505
+ "source_id": validate_id(raw.get("source_id"), "source id"),
506
+ "scene": normalize_scene(raw.get("scene")),
507
+ "placement": normalize_placement(placement) if isinstance(placement, dict) else None,
508
+ "label": {
509
+ "text": clean_text(label.get("text"), 8),
510
+ # Auto labels renumber when panels are rearranged; a label the user
511
+ # typed never does. One flag, so renumbering does not have to guess
512
+ # which of "A" and "A'" it is allowed to overwrite.
513
+ "auto": bool(label.get("auto", True)),
514
+ "visible": bool(label.get("visible", True)),
515
+ },
516
+ "title": clean_text(raw.get("title")),
517
+ "scalebar": {
518
+ "visible": bool(scalebar.get("visible", False)),
519
+ # None means "pick a round number that fits", decided at render time
520
+ # against the panel's actual physical width.
521
+ "target_um": (as_float(scalebar.get("target_um"), 0.0) or None),
522
+ },
523
+ "legend": {
524
+ "channels": bool(legend.get("channels", False)),
525
+ "plugins": bool(legend.get("plugins", False)),
526
+ },
527
+ "link_group": clean_id(raw.get("link_group")) or None,
528
+ "render_revision": max(0, as_int(raw.get("render_revision"), 0)),
529
+ "derived_from": {
530
+ "panel_id": clean_id(derived.get("panel_id")),
531
+ "operation": clean_text(derived.get("operation"), 40),
532
+ "layer": clean_text(derived.get("layer")),
533
+ } if derived else None,
534
+ "created_at": clean_text(raw.get("created_at")),
535
+ "updated_at": clean_text(raw.get("updated_at")),
536
+ }
537
+
538
+
539
+ def normalize_placement(raw):
540
+ """Where a panel sits ON THE PAGE, in millimetres.
541
+
542
+ Deliberately separate from `scene.viewport`, which is where it sits IN THE
543
+ IMAGE. Recropping the source and resizing the box on the page are different
544
+ intents, and collapsing them is what makes a linked split-channel row
545
+ impossible to keep in step.
546
+ """
547
+ return {
548
+ "page_id": validate_id(raw.get("page_id"), "page id"),
549
+ "x_mm": as_float(raw.get("x_mm"), 0.0),
550
+ "y_mm": as_float(raw.get("y_mm"), 0.0),
551
+ "w_mm": max(1.0, as_float(raw.get("w_mm"), 40.0)),
552
+ "h_mm": max(1.0, as_float(raw.get("h_mm"), 40.0)),
553
+ "z": as_int(raw.get("z"), 0),
554
+ }
555
+
556
+
557
+ def normalize_scene(raw):
558
+ """The captured viewer state -- everything that affects what is rendered.
559
+
560
+ Deliberately NOT captured: sidebar scroll, cursor, open tooltips, status
561
+ text. None of it changes a pixel of the science, and storing it would make
562
+ two identical captures compare unequal.
563
+ """
564
+ raw = raw if isinstance(raw, dict) else {}
565
+ version = raw.get("snapshot_version")
566
+ if isinstance(version, int) and not isinstance(version, bool) and version > SNAPSHOT_VERSION:
567
+ raise UnreadableFigure(
568
+ f"a panel in this figure was captured by a newer version of Plexora "
569
+ f"(snapshot {version}, this build reads {SNAPSHOT_VERSION})"
570
+ )
571
+
572
+ viewport = raw.get("viewport") if isinstance(raw.get("viewport"), dict) else {}
573
+ overlays = raw.get("core_overlays") if isinstance(raw.get("core_overlays"), dict) else {}
574
+
575
+ return {
576
+ "snapshot_version": SNAPSHOT_VERSION,
577
+ "source_id": clean_id(raw.get("source_id")),
578
+ # Full-resolution image pixels. Never CSS pixels, never viewport
579
+ # fractions, never coordinates at whatever pyramid level happened to be
580
+ # on screen -- those three are indistinguishable from these once written
581
+ # down, and only one of them can be re-rendered at any DPI.
582
+ "viewport": {
583
+ "x": as_float(viewport.get("x"), 0.0),
584
+ "y": as_float(viewport.get("y"), 0.0),
585
+ "w": max(1.0, as_float(viewport.get("w"), 1.0)),
586
+ "h": max(1.0, as_float(viewport.get("h"), 1.0)),
587
+ },
588
+ "channels": [normalize_scene_channel(c) for c in raw.get("channels") or []
589
+ if isinstance(c, dict) and clean_text(c.get("key"))],
590
+ "core_overlays": {
591
+ "cell_layers": [normalize_cell_layer(layer)
592
+ for layer in overlays.get("cell_layers") or []
593
+ if isinstance(layer, dict)],
594
+ "hd_tiles": bool(overlays.get("hd_tiles", False)),
595
+ "scalebar_visible": bool(overlays.get("scalebar_visible", False)),
596
+ },
597
+ # Opaque by contract: only the plugin that wrote a subtree reads it
598
+ # back. Figure Builder stores the name, the version and the blob, and
599
+ # the embedded `legend` so that export needs no live plugin JavaScript.
600
+ "plugins": normalize_plugin_states(raw.get("plugins")),
601
+ "captured_at": clean_text(raw.get("captured_at")),
602
+ }
603
+
604
+
605
+ def normalize_scene_channel(raw):
606
+ """One channel as it was displayed.
607
+
608
+ Identified by `key` -- the URL key the tile route uses -- rather than by the
609
+ marker name, because a name is something the user renames and a key is not.
610
+ `fullname_at_capture` rides along for the legend and for the "this channel
611
+ is gone" message; nothing resolves a channel by it.
612
+
613
+ `window` is raw 16-bit, always, whatever domain the slider was in. It is
614
+ what the project's own saved channel list stores, for the same reason: a
615
+ byte-domain window means nothing once HD mode is on.
616
+ """
617
+ window = raw.get("window")
618
+ if not isinstance(window, (list, tuple)) or len(window) != 2:
619
+ window = [0, 65535]
620
+ rgb = raw.get("color") if isinstance(raw.get("color"), dict) else {}
621
+ return {
622
+ "key": clean_text(raw.get("key")),
623
+ "fullname_at_capture": clean_text(raw.get("fullname_at_capture")),
624
+ "color": {
625
+ "r": clamp(as_int(rgb.get("r"), 255), 0, 255),
626
+ "g": clamp(as_int(rgb.get("g"), 255), 0, 255),
627
+ "b": clamp(as_int(rgb.get("b"), 255), 0, 255),
628
+ },
629
+ "window": [as_float(window[0], 0.0), as_float(window[1], 65535.0)],
630
+ "visible": bool(raw.get("visible", True)),
631
+ }
632
+
633
+
634
+ def normalize_cell_layer(raw):
635
+ return {
636
+ "name": clean_text(raw.get("name")),
637
+ "mode": clean_text(raw.get("mode"), 20),
638
+ "opacity": clamp(as_float(raw.get("opacity"), 1.0), 0.0, 1.0),
639
+ "visible": bool(raw.get("visible", True)),
640
+ "z": as_int(raw.get("z"), 0),
641
+ }
642
+
643
+
644
+ def normalize_plugin_states(raw):
645
+ out = {}
646
+ for name, entry in (raw if isinstance(raw, dict) else {}).items():
647
+ name = clean_text(name, 64)
648
+ if not name or not isinstance(entry, dict):
649
+ continue
650
+ out[name] = {
651
+ "version": clean_text(entry.get("version"), 64),
652
+ "state": entry.get("state") if isinstance(entry.get("state"), (dict, list)) else {},
653
+ "legend": [normalize_legend_entry(item) for item in entry.get("legend") or []
654
+ if isinstance(item, dict)],
655
+ }
656
+ return out
657
+
658
+
659
+ def normalize_legend_entry(raw):
660
+ """A legend row, computed by the plugin at capture time and stored.
661
+
662
+ Embedded rather than recomputed on export because export must work when the
663
+ plugin that produced it is not installed, not open, or a version behind. A
664
+ legend regenerated from a plugin that has since changed its palette is a
665
+ legend that disagrees with the panel above it.
666
+ """
667
+ kind = clean_text(raw.get("kind"), 20) or "categorical"
668
+ entry = {
669
+ "kind": kind if kind in ("categorical", "continuous", "channel") else "categorical",
670
+ "label": clean_text(raw.get("label")),
671
+ }
672
+ if entry["kind"] == "continuous":
673
+ entry["ramp"] = [color(c, "#000000") for c in raw.get("ramp") or []][:64]
674
+ domain = raw.get("domain") if isinstance(raw.get("domain"), (list, tuple)) else [0, 1]
675
+ entry["domain"] = [as_float(v, 0.0) for v in list(domain)[:2]] or [0.0, 1.0]
676
+ else:
677
+ entry["color"] = color(raw.get("color"), "#ffffff")
678
+ return entry
679
+
680
+
681
+ # -- annotations and groups ---------------------------------------------
682
+
683
+
684
+ def normalize_annotation(raw):
685
+ kind = clean_text(raw.get("type"), 20)
686
+ if kind not in ANNOTATION_TYPES:
687
+ raise ValueError(f"unknown annotation type {kind!r}")
688
+ geometry = raw.get("geometry") if isinstance(raw.get("geometry"), dict) else {}
689
+ style = raw.get("style") if isinstance(raw.get("style"), dict) else {}
690
+ align = clean_text(style.get("align"), 8)
691
+ valign = clean_text(style.get("valign"), 8)
692
+ normalized = {
693
+ "annotation_id": validate_id(raw.get("annotation_id"), "annotation id"),
694
+ "type": kind,
695
+ "page_id": validate_id(raw.get("page_id"), "page id"),
696
+ "geometry": {
697
+ "x_mm": as_float(geometry.get("x_mm"), 0.0),
698
+ "y_mm": as_float(geometry.get("y_mm"), 0.0),
699
+ "w_mm": as_float(geometry.get("w_mm"), 20.0),
700
+ "h_mm": as_float(geometry.get("h_mm"), 10.0),
701
+ "rotation": as_float(geometry.get("rotation"), 0.0),
702
+ },
703
+ "text": clean_text(raw.get("text"), MAX_TEXT_LENGTH),
704
+ "style": {
705
+ "color": color(style.get("color"), "#000000"),
706
+ "fill": color(style.get("fill"), "") if style.get("fill") else "",
707
+ "line_width_pt": clamp(as_float(style.get("line_width_pt"), 0.75), 0.0, 20.0),
708
+ "font_size_pt": clamp(
709
+ as_float(style.get("font_size_pt"), textmetrics.DEFAULT_TEXT_SIZE_PT),
710
+ 1.0, 200.0),
711
+ "font_family": textmetrics.family(clean_text(style.get("font_family"), 20)),
712
+ "align": align if align in ("left", "center", "right", "justify") else "left",
713
+ "line_height": clamp(as_float(style.get("line_height"),
714
+ textmetrics.LINE_HEIGHT), 0.8, 3.0),
715
+ "valign": valign if valign in ("top", "middle", "bottom") else "top",
716
+ "autofit": bool(style.get("autofit", True)),
717
+ },
718
+ "z": as_int(raw.get("z"), 0),
719
+ }
720
+ if kind == "text":
721
+ # `rich` is the words; `text` is a projection of it, recomputed here and
722
+ # never trusted from the caller. One gate, one answer -- the alternative
723
+ # is two fields that drift the first time an operation updates one.
724
+ #
725
+ # Only a text annotation carries it. A `rich` key on a rectangle would
726
+ # be a field with no meaning, and `_update_annotation`'s merge would
727
+ # carry it forever once one arrived.
728
+ normalized["rich"] = normalize_rich_text(normalized["text"], raw.get("rich"))
729
+ normalized["text"] = plain_text(normalized["rich"])
730
+ return normalized
731
+
732
+
733
+ def normalize_rich_text(flat, raw=None):
734
+ """A text annotation's words, as lines of styled runs.
735
+
736
+ Additive beside `text`, and deliberately NOT a `SCHEMA_VERSION` bump. The
737
+ precedent is `visual_groups`: a document written before this key existed
738
+ simply has none, and a build that predates the key drops it and keeps the
739
+ words. Bumping the version instead would make every figure that has ever
740
+ held a text box refuse to open in every build already installed, to prevent
741
+ a loss whose whole content is "the bold went away". The degradation is
742
+ silent -- there is no marker saying this text was once formatted -- and a
743
+ field whose only job is to describe another field would be worse.
744
+
745
+ A LINE, not a paragraph, is the unit. Only the browser can measure a string,
746
+ so only the browser can decide where a line breaks, and the break it chose
747
+ is therefore stored rather than recomputed by an exporter that would have to
748
+ guess. `hard` records whose break it is: True when the author pressed Enter,
749
+ False when the wrap put it there and a later re-wrap may move it.
750
+
751
+ One consequence to be honest about: a figure edited through the REST surface
752
+ with no browser present keeps the breaks it had, even if the box was resized
753
+ in the same edit. The words stay right and the breaks go stale, which is
754
+ visible on the page rather than silently wrong in the PDF.
755
+
756
+ Never raises. `normalize_document` skips an annotation whose normaliser
757
+ raises, so a ValueError here would silently DELETE the user's text box on
758
+ the next read -- oversized input is truncated instead.
759
+ """
760
+ lines = raw.get("lines") if isinstance(raw, dict) else None
761
+ if not isinstance(lines, list):
762
+ return _rich_from_plain(flat)
763
+
764
+ budget = MAX_TEXT_LENGTH
765
+ runs_left = MAX_TEXT_RUNS
766
+ out = []
767
+ for raw_line in lines[:MAX_TEXT_LINES]:
768
+ if budget <= 0 or runs_left <= 0:
769
+ break
770
+ if not isinstance(raw_line, dict):
771
+ continue
772
+ raw_runs = raw_line.get("runs")
773
+ runs = []
774
+ # Not sliced: the list is already parsed and in memory, and every run
775
+ # that survives costs at least one character, so the character budget
776
+ # below is what bounds the output. Slicing the INPUT instead would drop
777
+ # a caption that happened to arrive behind a long tail of empty spans.
778
+ for raw_run in (raw_runs if isinstance(raw_runs, list) else []):
779
+ if budget <= 0:
780
+ break
781
+ run = _normalize_run(raw_run, budget)
782
+ if run is None:
783
+ continue
784
+ budget -= len(run["text"])
785
+ runs.append(run)
786
+ # Coalesce BEFORE capping. A word processor marks up every letter, so a
787
+ # pasted sentence arrives as one run per character -- capping first
788
+ # would throw away the tail of the sentence, which is precisely the
789
+ # input coalescing exists to absorb.
790
+ runs = _cap_runs(_coalesce_runs(runs), min(MAX_RUNS_PER_LINE, max(0, runs_left)))
791
+ runs_left -= len(runs)
792
+ # An empty line in the middle is content -- it is the blank line between
793
+ # two paragraphs of a caption -- so it is kept rather than dropped.
794
+ out.append({"hard": bool(raw_line.get("hard", True)), "runs": runs})
795
+
796
+ while out and not out[-1]["runs"]:
797
+ out.pop()
798
+ if not out:
799
+ return _rich_from_plain("")
800
+ # Nothing sits above the first line for it to have wrapped from, so its
801
+ # break is the author's by definition. Left as read, a `hard: false` first
802
+ # line would make a re-wrap try to join it to the line before it.
803
+ out[0]["hard"] = True
804
+ return {"lines": out}
805
+
806
+
807
+ def plain_text(rich):
808
+ """The flat string a rich text reads as. The `text` field is this."""
809
+ lines = rich.get("lines") if isinstance(rich, dict) else None
810
+ if not isinstance(lines, list):
811
+ return ""
812
+ return "\n".join(
813
+ "".join(run["text"] for run in line["runs"]) for line in lines
814
+ )[:MAX_TEXT_LENGTH]
815
+
816
+
817
+ def _rich_from_plain(flat):
818
+ """Lines from a flat string -- the path every pre-`rich` document takes."""
819
+ text = _normalize_breaks(flat if isinstance(flat, str) else "")
820
+ out = []
821
+ for raw_line in text.split("\n")[:MAX_TEXT_LINES]:
822
+ cleaned = _clean_run_text(raw_line, MAX_TEXT_LENGTH)
823
+ out.append({"hard": True, "runs": [{"text": cleaned}] if cleaned else []})
824
+ return {"lines": out or [{"hard": True, "runs": []}]}
825
+
826
+
827
+ def _normalize_run(raw, budget):
828
+ """One styled span, or None if there is nothing left of it.
829
+
830
+ A run carries only what it OVERRIDES. An absent `size_pt` means "whatever
831
+ the box says", so raising the box's font size still reaches every run the
832
+ user never touched; writing the resolved value onto every run instead would
833
+ freeze each one at the size it happened to be created at.
834
+ """
835
+ if not isinstance(raw, dict):
836
+ return None
837
+ text = _clean_run_text(raw.get("text"), budget)
838
+ if not text:
839
+ return None
840
+ run = {"text": text}
841
+ for mark in ("bold", "italic", "underline", "strike"):
842
+ if raw.get(mark):
843
+ run[mark] = True
844
+ name = clean_text(raw.get("family"), 20)
845
+ if name in textmetrics.FAMILIES:
846
+ run["family"] = name
847
+ size = as_float(raw.get("size_pt"), None)
848
+ if size is not None:
849
+ run["size_pt"] = clamp(size, 1.0, 200.0)
850
+ if isinstance(raw.get("color"), str) and COLOR_PATTERN.match(raw["color"]):
851
+ run["color"] = raw["color"].lower()
852
+ return run
853
+
854
+
855
+ def _coalesce_runs(runs):
856
+ """Adjacent runs with identical marks become one.
857
+
858
+ Load-bearing, not tidiness. A contenteditable emits a fresh span per
859
+ keystroke, so without this a caption grows a run per character: the document
860
+ balloons, the "did anything change?" check before a commit never fires
861
+ because the shape differs every time, and the client and server normalisers
862
+ can no longer be compared for equality at all. Coalescing is what makes the
863
+ stored form canonical.
864
+ """
865
+ out = []
866
+ for run in runs:
867
+ if out and _run_marks(out[-1]) == _run_marks(run):
868
+ out[-1]["text"] += run["text"]
869
+ else:
870
+ out.append(run)
871
+ return out
872
+
873
+
874
+ def _cap_runs(runs, cap):
875
+ """At most `cap` runs, keeping every word.
876
+
877
+ The overflow is folded into the last surviving run rather than sliced off.
878
+ A line with more than a hundred DISTINCTLY styled spans is already beyond
879
+ anything a caption needs, but the words on it are still the user's -- so the
880
+ cap costs them the marks past that point and never the sentence. Same trade
881
+ the whole feature makes when it degrades.
882
+ """
883
+ if cap <= 0:
884
+ return []
885
+ if len(runs) <= cap:
886
+ return runs
887
+ kept = runs[:cap]
888
+ kept[-1] = {**kept[-1], "text": kept[-1]["text"] + "".join(
889
+ run["text"] for run in runs[cap:])}
890
+ return kept
891
+
892
+
893
+ def _run_marks(run):
894
+ return tuple(run.get(key) for key in
895
+ ("bold", "italic", "underline", "strike", "family", "size_pt", "color"))
896
+
897
+
898
+ def _normalize_breaks(value):
899
+ """CRLF and CR become LF, tabs become a space.
900
+
901
+ `_CONTROL_CHARS` deliberately lets \\t, \\n and \\r through, so a paste out of
902
+ a Windows word processor otherwise leaves a bare CR sitting inside a run,
903
+ where it is a line break that the line list knows nothing about.
904
+ """
905
+ return value.replace("\r\n", "\n").replace("\r", "\n").replace("\t", " ")
906
+
907
+
908
+ def _clean_run_text(value, budget):
909
+ """Run text, capped -- and NOT stripped.
910
+
911
+ `clean_text` strips, which is right for a title and wrong here: the spaces
912
+ around a run are the gaps between it and its neighbours on the same line,
913
+ so stripping them closes up "Fig. 1a" + " DAPI" into "Fig. 1aDAPI".
914
+ """
915
+ if value is None:
916
+ return ""
917
+ if not isinstance(value, str):
918
+ value = str(value)
919
+ return _RUN_CONTROL_CHARS.sub("", value.replace("\t", " "))[:max(0, budget)]
920
+
921
+
922
+ def normalize_link_group(raw):
923
+ sync = [s for s in (raw.get("sync") or []) if s in ("viewport", "crop", "size", "channels")]
924
+ return {
925
+ "group_id": validate_id(raw.get("group_id"), "group id"),
926
+ "panel_ids": [clean_id(p) for p in raw.get("panel_ids") or [] if clean_id(p)],
927
+ "sync": sync or ["viewport"],
928
+ }
929
+
930
+
931
+ def normalize_group(raw):
932
+ """A visual group: things that move, and are selected, together.
933
+
934
+ NOT a link group, and the distinction is the reason there are two. A link
935
+ group says "these panels show the same field at the same size", is created
936
+ by Split Composite, and its whole job is to propagate an edit from one
937
+ member to the others. A visual group says "an image and its title are one
938
+ object as far as clicking and dragging are concerned", is created by the
939
+ user pressing Cmd+G, and propagates nothing.
940
+
941
+ They also hold different things. A link group holds panels, because
942
+ synchronising a viewport onto a text box is meaningless; a visual group
943
+ holds panels AND annotations, because image-plus-caption is the commonest
944
+ reason to want one.
945
+
946
+ Reusing link_groups for this would have meant a group whose `sync` list is
947
+ empty behaving as a different feature -- and the split rows already in
948
+ people's figures occupying the key.
949
+ """
950
+ return {
951
+ "group_id": validate_id(raw.get("group_id"), "group id"),
952
+ "member_ids": [clean_id(m) for m in raw.get("member_ids") or [] if clean_id(m)],
953
+ }
954
+
955
+
956
+
957
+ # -- primitives ---------------------------------------------------------
958
+
959
+
960
+ def validate_id(value, what):
961
+ if not isinstance(value, str) or not ID_PATTERN.match(value):
962
+ raise ValueError(f"invalid {what}: {value!r}")
963
+ return value
964
+
965
+
966
+ def clean_id(value):
967
+ """An id if it is one, "" otherwise -- for fields where absence is legal."""
968
+ return value if isinstance(value, str) and ID_PATTERN.match(value) else ""
969
+
970
+
971
+ def validate_figure_id(value):
972
+ """A figure id that is safe to use as a directory name, or a ValueError.
973
+
974
+ Checked at every entry point rather than once at creation: this value
975
+ arrives from a URL, and it is joined onto a filesystem path.
976
+ """
977
+ if not isinstance(value, str) or not FIGURE_ID_PATTERN.match(value):
978
+ raise ValueError(f"invalid figure id: {value!r}")
979
+ return value
980
+
981
+
982
+ def clean_text(value, limit=MAX_TITLE_LENGTH):
983
+ """User-typed text, made safe to store and hand back.
984
+
985
+ Control characters go and length is capped. Nothing is HTML-escaped here --
986
+ escaping at the store double-escapes on every round trip, and the place that
987
+ has to be careful is the one putting it in the DOM.
988
+ """
989
+ if value is None:
990
+ return ""
991
+ if not isinstance(value, str):
992
+ value = str(value)
993
+ return _CONTROL_CHARS.sub("", value).strip()[:limit]
994
+
995
+
996
+ def color(value, fallback="#000000"):
997
+ if isinstance(value, str) and COLOR_PATTERN.match(value):
998
+ return value.lower()
999
+ return fallback
1000
+
1001
+
1002
+ def as_int(value, fallback):
1003
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
1004
+ return fallback
1005
+ try:
1006
+ return int(value)
1007
+ except (ValueError, OverflowError):
1008
+ return fallback
1009
+
1010
+
1011
+ def as_float(value, fallback):
1012
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
1013
+ return fallback
1014
+ value = float(value)
1015
+ # NaN and infinity survive json.loads and then poison every arithmetic they
1016
+ # touch -- a panel at x=NaN simply never draws, with no error anywhere.
1017
+ if value != value or value in (float("inf"), float("-inf")):
1018
+ return fallback
1019
+ return value
1020
+
1021
+
1022
+ def clamp(value, low, high):
1023
+ return max(low, min(high, value))