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.
- plexora/__init__.py +129 -0
- plexora/__main__.py +15 -0
- plexora/_url.py +105 -0
- plexora/api/__init__.py +55 -0
- plexora/api/dataset.py +398 -0
- plexora/api/http.py +19 -0
- plexora/api/plugin.py +541 -0
- plexora/api/store.py +221 -0
- plexora/cli.py +777 -0
- plexora/client/dist/354_bundle.js +2 -0
- plexora/client/dist/354_bundle.js.LICENSE.txt +20 -0
- plexora/client/dist/418_bundle.js +2 -0
- plexora/client/dist/418_bundle.js.LICENSE.txt +1 -0
- plexora/client/dist/693b935cb1f907814be9c6199a68c217.svg +19 -0
- plexora/client/dist/713c134ff47cd328a5b711526f151082.svg +18 -0
- plexora/client/dist/770_bundle.js +1 -0
- plexora/client/dist/ff743f408972e0e96ca9153ed5ae4c83.svg +23 -0
- plexora/client/dist/vendor_bundle.js +2 -0
- plexora/client/dist/vendor_bundle.js.LICENSE.txt +308 -0
- plexora/client/external/openseadragon-bin-2.4.0/LICENSE.txt +28 -0
- plexora/client/external/openseadragon-bin-2.4.0/canvas-overlay-hd.js +140 -0
- plexora/client/external/openseadragon-bin-2.4.0/changelog.txt +501 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/.gitattributes +17 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/LICENSE.txt +116 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/Toolbar.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/fullpage.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/home.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/next.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/previous.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateleft.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateright.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/zoom.psd +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/README.md +9 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_grouphover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_hover.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_pressed.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_rest.png +0 -0
- plexora/client/external/openseadragon-bin-2.4.0/openseadragon-scalebar.js +562 -0
- plexora/client/src/css/import.css +474 -0
- plexora/client/src/css/main.css +903 -0
- plexora/client/src/css/openProject.css +363 -0
- plexora/client/src/css/quickView.css +166 -0
- plexora/client/src/css/tokens.css +51 -0
- plexora/client/src/css/viewer.css +1655 -0
- plexora/client/src/img/apple-touch-icon.png +0 -0
- plexora/client/src/img/favicon.ico +0 -0
- plexora/client/src/img/logo.ai +8892 -33
- plexora/client/src/img/logo.svg +1 -0
- plexora/client/src/img/logo_with_text.ai +9011 -34
- plexora/client/src/img/logo_with_text.svg +1 -0
- plexora/client/src/js/main.js +704 -0
- plexora/client/src/js/pluginRegistry.js +108 -0
- plexora/client/src/js/services/appStatus.js +286 -0
- plexora/client/src/js/services/browsePicker.js +73 -0
- plexora/client/src/js/services/dataLayer.js +400 -0
- plexora/client/src/js/services/datasetContext.js +143 -0
- plexora/client/src/js/services/glRenderer.js +143 -0
- plexora/client/src/js/services/importFormValidation.js +257 -0
- plexora/client/src/js/services/numericData.js +100 -0
- plexora/client/src/js/services/passVariablesToFrontend.js +20 -0
- plexora/client/src/js/services/simpleEventHandler.js +25 -0
- plexora/client/src/js/vendor.js +29 -0
- plexora/client/src/js/views/channelList.js +663 -0
- plexora/client/src/js/views/colorSwatchPicker.js +202 -0
- plexora/client/src/js/views/columnClassifier.js +146 -0
- plexora/client/src/js/views/coordinateField.js +133 -0
- plexora/client/src/js/views/dataSourceField.js +276 -0
- plexora/client/src/js/views/imageViewer.js +3306 -0
- plexora/client/src/js/views/miniMap.js +787 -0
- plexora/client/src/js/views/navbarControls.js +178 -0
- plexora/client/src/js/views/openProjectPage.js +239 -0
- plexora/client/src/js/views/projectEdit.js +335 -0
- plexora/client/src/js/views/quickViewLanding.js +136 -0
- plexora/client/src/js/views/rainbow.js +326 -0
- plexora/client/src/js/views/requirementsModal.js +572 -0
- plexora/client/src/js/views/rgbImageViewer.js +115 -0
- plexora/client/src/js/views/searchableSelect.js +411 -0
- plexora/client/src/js/views/segmentationProgress.js +122 -0
- plexora/client/src/js/views/toolLoader.js +747 -0
- plexora/client/src/js/views/viewerControls.js +689 -0
- plexora/client/src/js/views/viewerManager.js +434 -0
- plexora/client/src/js/views/viewerSidebar.js +1117 -0
- plexora/client/src/js/workers/tileDecoder.js +130 -0
- plexora/client/src/shaders/frag.glsl +528 -0
- plexora/client/src/shaders/vert.glsl +12 -0
- plexora/client/templates/base.html +212 -0
- plexora/client/templates/index.html +238 -0
- plexora/client/templates/open_project.html +96 -0
- plexora/client/templates/project_columns.html +86 -0
- plexora/client/templates/project_edit.html +196 -0
- plexora/client/templates/upload.html +118 -0
- plexora/connect.py +528 -0
- plexora/datasource.py +782 -0
- plexora/jupyter.py +406 -0
- plexora/notebook_env.py +232 -0
- plexora/paths.py +486 -0
- plexora/plugins/__init__.py +12 -0
- plexora/plugins/cell_explorer/__init__.py +100 -0
- plexora/plugins/cell_explorer/server/__init__.py +11 -0
- plexora/plugins/cell_explorer/server/routes.py +167 -0
- plexora/plugins/cell_explorer/server/state.py +281 -0
- plexora/plugins/cell_explorer/server/values.py +145 -0
- plexora/plugins/cell_explorer/server/variables.py +367 -0
- plexora/plugins/cell_explorer/static/cellExplorerApi.js +158 -0
- plexora/plugins/cell_explorer/static/cellExplorerColors.js +316 -0
- plexora/plugins/cell_explorer/static/cellExplorerContinuous.js +403 -0
- plexora/plugins/cell_explorer/static/cellExplorerFigureBridge.js +133 -0
- plexora/plugins/cell_explorer/static/cellExplorerLegend.js +188 -0
- plexora/plugins/cell_explorer/static/cellExplorerRoiBridge.js +822 -0
- plexora/plugins/cell_explorer/static/cellExplorerSidebarController.js +826 -0
- plexora/plugins/cell_explorer/static/cellExplorerState.js +372 -0
- plexora/plugins/cell_explorer/static/cell_explorer.css +752 -0
- plexora/plugins/cell_explorer/templates/cell_explorer/panel.html +137 -0
- plexora/plugins/figure_builder/__init__.py +125 -0
- plexora/plugins/figure_builder/server/__init__.py +7 -0
- plexora/plugins/figure_builder/server/compose.py +365 -0
- plexora/plugins/figure_builder/server/export.py +784 -0
- plexora/plugins/figure_builder/server/export_jobs.py +148 -0
- plexora/plugins/figure_builder/server/operations.py +556 -0
- plexora/plugins/figure_builder/server/pixels.py +142 -0
- plexora/plugins/figure_builder/server/provenance.py +174 -0
- plexora/plugins/figure_builder/server/render.py +278 -0
- plexora/plugins/figure_builder/server/repository.py +701 -0
- plexora/plugins/figure_builder/server/routes.py +594 -0
- plexora/plugins/figure_builder/server/schema.py +1023 -0
- plexora/plugins/figure_builder/server/sources.py +120 -0
- plexora/plugins/figure_builder/server/textmetrics.py +155 -0
- plexora/plugins/figure_builder/static/figureActions.js +363 -0
- plexora/plugins/figure_builder/static/figureBuilderApi.js +255 -0
- plexora/plugins/figure_builder/static/figureCanvas.js +2362 -0
- plexora/plugins/figure_builder/static/figureCaptureBoxes.js +369 -0
- plexora/plugins/figure_builder/static/figureCaptureDock.js +466 -0
- plexora/plugins/figure_builder/static/figureCaptureTool.js +1034 -0
- plexora/plugins/figure_builder/static/figureConfirm.js +136 -0
- plexora/plugins/figure_builder/static/figureContextBar.js +1193 -0
- plexora/plugins/figure_builder/static/figureContextMenu.js +253 -0
- plexora/plugins/figure_builder/static/figureDocumentState.js +275 -0
- plexora/plugins/figure_builder/static/figureExportUi.js +174 -0
- plexora/plugins/figure_builder/static/figureLibrary.js +247 -0
- plexora/plugins/figure_builder/static/figureQuickEdit.js +628 -0
- plexora/plugins/figure_builder/static/figureRichText.js +634 -0
- plexora/plugins/figure_builder/static/figureSceneSnapshot.js +361 -0
- plexora/plugins/figure_builder/static/figureSchema.js +218 -0
- plexora/plugins/figure_builder/static/figureSidebarController.js +1159 -0
- plexora/plugins/figure_builder/static/figureTextEditor.js +503 -0
- plexora/plugins/figure_builder/static/figureTextPanel.js +491 -0
- plexora/plugins/figure_builder/static/figureViewOptions.js +273 -0
- plexora/plugins/figure_builder/static/figureWorkspace.js +2030 -0
- plexora/plugins/figure_builder/static/figure_builder.css +3292 -0
- plexora/plugins/figure_builder/templates/figure_builder/library.html +74 -0
- plexora/plugins/figure_builder/templates/figure_builder/workspace.html +27 -0
- plexora/plugins/figure_builder/templates/figure_builder/workspace_body.html +458 -0
- plexora/plugins/gating/__init__.py +76 -0
- plexora/plugins/gating/server/__init__.py +0 -0
- plexora/plugins/gating/server/anndata_gates.py +367 -0
- plexora/plugins/gating/server/database.py +16 -0
- plexora/plugins/gating/server/model.py +320 -0
- plexora/plugins/gating/server/routes.py +235 -0
- plexora/plugins/gating/static/csvGatingList.js +947 -0
- plexora/plugins/gating/static/gating.css +289 -0
- plexora/plugins/gating/static/gatingApi.js +221 -0
- plexora/plugins/gating/static/gatingSidebarController.js +513 -0
- plexora/plugins/gating/templates/gating/legacy.html +26 -0
- plexora/plugins/gating/templates/gating/panel.html +69 -0
- plexora/plugins/roi/__init__.py +95 -0
- plexora/plugins/roi/server/__init__.py +8 -0
- plexora/plugins/roi/server/adapters.py +655 -0
- plexora/plugins/roi/server/geojson.py +267 -0
- plexora/plugins/roi/server/geometry.py +181 -0
- plexora/plugins/roi/server/mapping.py +169 -0
- plexora/plugins/roi/server/operations.py +293 -0
- plexora/plugins/roi/server/repository.py +215 -0
- plexora/plugins/roi/server/routes.py +409 -0
- plexora/plugins/roi/server/schema.py +323 -0
- plexora/plugins/roi/static/roi.css +583 -0
- plexora/plugins/roi/static/roiApi.js +177 -0
- plexora/plugins/roi/static/roiFigureBridge.js +107 -0
- plexora/plugins/roi/static/roiGeometry.js +350 -0
- plexora/plugins/roi/static/roiRenderer.js +278 -0
- plexora/plugins/roi/static/roiSidebarController.js +1038 -0
- plexora/plugins/roi/static/roiState.js +518 -0
- plexora/plugins/roi/static/roiTools.js +993 -0
- plexora/plugins/roi/templates/roi/panel.html +240 -0
- plexora/proxy.py +57 -0
- plexora/server/models/adapters/__init__.py +94 -0
- plexora/server/models/adapters/anndata_adapter.py +388 -0
- plexora/server/models/adapters/base.py +93 -0
- plexora/server/models/adapters/classify.py +192 -0
- plexora/server/models/adapters/csv_adapter.py +105 -0
- plexora/server/models/adapters/inspection.py +265 -0
- plexora/server/models/adapters/spatialdata_adapter.py +248 -0
- plexora/server/models/centroid_tiles.py +305 -0
- plexora/server/models/data_model.py +1659 -0
- plexora/server/models/database_model.py +169 -0
- plexora/server/models/project.py +1270 -0
- plexora/server/plugins.py +199 -0
- plexora/server/routes/browse_routes.py +41 -0
- plexora/server/routes/data_routes.py +294 -0
- plexora/server/routes/import_routes.py +603 -0
- plexora/server/routes/page_routes.py +139 -0
- plexora/server/routes/project_routes.py +438 -0
- plexora/server/routes/quick_view_routes.py +62 -0
- plexora/server/routes/system_routes.py +39 -0
- plexora/server/routes/tool_routes.py +418 -0
- plexora/server/utils/addHEColumns.py +11 -0
- plexora/server/utils/fast_png.py +97 -0
- plexora/server/utils/fullConversion.py +50 -0
- plexora/server/utils/native_dialog.py +168 -0
- plexora/server/utils/pre_normalization.py +47 -0
- plexora/server/utils/segmentation_pyramid.py +598 -0
- plexora/server/utils/smallestenclosingcircle.py +126 -0
- plexora/server/utils/tiffsurgeon.py +372 -0
- plexora/server_cli.py +48 -0
- plexora/test.py +0 -0
- plexora-0.0.1.dist-info/METADATA +341 -0
- plexora-0.0.1.dist-info/RECORD +248 -0
- plexora-0.0.1.dist-info/WHEEL +5 -0
- plexora-0.0.1.dist-info/entry_points.txt +12 -0
- plexora-0.0.1.dist-info/licenses/LICENSE +207 -0
- 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))
|