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,634 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A text annotation's words, as lines of styled runs.
|
|
3
|
+
*
|
|
4
|
+
* This is the browser's half of a rule written twice. `server/schema.py` holds
|
|
5
|
+
* the other half and `server/textmetrics.py` the constants, and
|
|
6
|
+
* `test_the_client_and_the_server_normalise_rich_text_the_same_way` asserts the
|
|
7
|
+
* two normalisers agree on a table of deliberately awkward inputs. They have to
|
|
8
|
+
* agree exactly: the canvas draws from what this file produces and the document
|
|
9
|
+
* stores what Python produces, so a disagreement shows the user one thing and
|
|
10
|
+
* saves another, and they find out on reload.
|
|
11
|
+
*
|
|
12
|
+
* The same arrangement already exists in this plugin for arrowheads
|
|
13
|
+
* (`FigureCanvas.arrowHeadPoints` and `export._arrow_head`). One rule, two
|
|
14
|
+
* languages, pinned by a test.
|
|
15
|
+
*
|
|
16
|
+
* Everything here is PURE -- no DOM, no canvas, no measurement. That is what
|
|
17
|
+
* lets the node probe exercise it. The one thing that genuinely needs a browser,
|
|
18
|
+
* measuring how wide a string is, arrives as the `measure` argument to
|
|
19
|
+
* `rewrap()` rather than being reached for inside it.
|
|
20
|
+
*/
|
|
21
|
+
class FigureRichText {
|
|
22
|
+
|
|
23
|
+
// -- constants, mirrored from server/textmetrics.py --------------------
|
|
24
|
+
|
|
25
|
+
/** Millimetres per typographic point. Computed, never the rounded literal:
|
|
26
|
+
* `FigureCanvas.PT_PER_MM` is 2.8346 and rounding here would put a floor
|
|
27
|
+
* under how exactly the canvas and the exporter can be asserted to agree. */
|
|
28
|
+
static get MM_PER_PT() { return 25.4 / 72; }
|
|
29
|
+
|
|
30
|
+
/** Line box height as a multiple of the type size. Was `line-height: 1.25`
|
|
31
|
+
* in the stylesheet; the canvas no longer leaves leading to the browser, so
|
|
32
|
+
* it has to live where both languages can read it. */
|
|
33
|
+
static get LINE_HEIGHT() { return 1.2; }
|
|
34
|
+
|
|
35
|
+
/** The type size a NEW text box starts at, in points. Not
|
|
36
|
+
* `settings.style.font_size_pt` -- that one is the furniture drawn on a
|
|
37
|
+
* panel, and has to stay small enough to sit inside the image. See
|
|
38
|
+
* textmetrics.DEFAULT_TEXT_SIZE_PT for the whole of it. */
|
|
39
|
+
static get DEFAULT_SIZE_PT() { return 14.0; }
|
|
40
|
+
|
|
41
|
+
/** Ascent and descent as a fraction of the em, from the Adobe core AFM
|
|
42
|
+
* files. Used to centre a line in its box, which is what makes vertical
|
|
43
|
+
* alignment and mixed-size lines land in the same place everywhere. */
|
|
44
|
+
static get ASCENT() {
|
|
45
|
+
return { "Helvetica": 0.718, "Times-Roman": 0.683, "Courier": 0.629 };
|
|
46
|
+
}
|
|
47
|
+
static get DESCENT() {
|
|
48
|
+
return { "Helvetica": 0.207, "Times-Roman": 0.217, "Courier": 0.157 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static get UNDERLINE_OFFSET_EM() { return 0.12; }
|
|
52
|
+
static get UNDERLINE_THICKNESS_EM() { return 0.06; }
|
|
53
|
+
static get STRIKE_OFFSET_EM() { return 0.26; }
|
|
54
|
+
|
|
55
|
+
/** PostScript base names, not "sans"/"serif": `settings.style.font_family`
|
|
56
|
+
* already holds "Helvetica" and hands it to reportlab unchanged. */
|
|
57
|
+
static get FAMILIES() { return ["Helvetica", "Times-Roman", "Courier"]; }
|
|
58
|
+
static get DEFAULT_FAMILY() { return "Helvetica"; }
|
|
59
|
+
|
|
60
|
+
/** What to ask the browser for, per family. Each stack names substitutes
|
|
61
|
+
* drawn to the core font's widths -- Arial and Liberation Sans were both
|
|
62
|
+
* built to Helvetica's metrics -- so a line that fits on screen fits in
|
|
63
|
+
* the PDF. That is what makes storing the browser's line breaks safe. */
|
|
64
|
+
static get CSS_STACK() {
|
|
65
|
+
return {
|
|
66
|
+
"Helvetica": 'Helvetica, Arial, "Liberation Sans", sans-serif',
|
|
67
|
+
"Times-Roman": '"Times New Roman", Times, "Liberation Serif", serif',
|
|
68
|
+
"Courier": '"Courier New", Courier, "Liberation Mono", monospace',
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
static get MAX_TEXT_LENGTH() { return 4000; }
|
|
73
|
+
static get MAX_TEXT_LINES() { return 200; }
|
|
74
|
+
static get MAX_RUNS_PER_LINE() { return 100; }
|
|
75
|
+
static get MAX_TEXT_RUNS() { return 500; }
|
|
76
|
+
|
|
77
|
+
/** The marks a run may carry, in the order they are written. Key order is
|
|
78
|
+
* not semantic, but keeping it fixed keeps a JSON diff readable. */
|
|
79
|
+
static get MARKS() {
|
|
80
|
+
return ["bold", "italic", "underline", "strike", "family", "size_pt", "color"];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static family(value) {
|
|
84
|
+
return FigureRichText.FAMILIES.includes(value)
|
|
85
|
+
? value : FigureRichText.DEFAULT_FAMILY;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
static cssStack(value) {
|
|
89
|
+
return FigureRichText.CSS_STACK[FigureRichText.family(value)];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// -- normalising -------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Lines of runs, budgeted and coalesced. Mirrors `schema.normalize_rich_text`.
|
|
96
|
+
*
|
|
97
|
+
* `flat` is the plain string the annotation already had; `raw` is a rich
|
|
98
|
+
* structure if there is one. No rich structure means an older document (or
|
|
99
|
+
* an older client), and it is rebuilt from the flat string -- which is the
|
|
100
|
+
* whole migration, and why this feature needed no schema version bump.
|
|
101
|
+
*
|
|
102
|
+
* Never throws. Oversized input is truncated, because the server's
|
|
103
|
+
* equivalent skipping an annotation would delete the user's text box.
|
|
104
|
+
*/
|
|
105
|
+
static normalize(flat, raw) {
|
|
106
|
+
const lines = raw && Array.isArray(raw.lines) ? raw.lines : null;
|
|
107
|
+
if (!lines) return FigureRichText.fromPlain(flat);
|
|
108
|
+
|
|
109
|
+
let budget = FigureRichText.MAX_TEXT_LENGTH;
|
|
110
|
+
let runsLeft = FigureRichText.MAX_TEXT_RUNS;
|
|
111
|
+
const out = [];
|
|
112
|
+
for (const rawLine of lines.slice(0, FigureRichText.MAX_TEXT_LINES)) {
|
|
113
|
+
if (budget <= 0 || runsLeft <= 0) break;
|
|
114
|
+
if (!rawLine || typeof rawLine !== "object") continue;
|
|
115
|
+
const rawRuns = Array.isArray(rawLine.runs) ? rawLine.runs : [];
|
|
116
|
+
let runs = [];
|
|
117
|
+
// Not sliced: every run that survives costs at least one character,
|
|
118
|
+
// so the character budget is what bounds this. Slicing the input
|
|
119
|
+
// would drop a caption sitting behind a tail of empty spans.
|
|
120
|
+
for (const rawRun of rawRuns) {
|
|
121
|
+
if (budget <= 0) break;
|
|
122
|
+
const run = FigureRichText.normalizeRun(rawRun, budget);
|
|
123
|
+
if (!run) continue;
|
|
124
|
+
budget -= run.text.length;
|
|
125
|
+
runs.push(run);
|
|
126
|
+
}
|
|
127
|
+
// Coalesce BEFORE capping: a word processor marks up every letter,
|
|
128
|
+
// so a pasted sentence arrives as one run per character and capping
|
|
129
|
+
// first would throw away the end of it.
|
|
130
|
+
runs = FigureRichText.capRuns(
|
|
131
|
+
FigureRichText.coalesce(runs),
|
|
132
|
+
Math.min(FigureRichText.MAX_RUNS_PER_LINE, Math.max(0, runsLeft)));
|
|
133
|
+
runsLeft -= runs.length;
|
|
134
|
+
// An empty line in the middle is content -- the blank line between
|
|
135
|
+
// two paragraphs of a caption -- so it is kept.
|
|
136
|
+
out.push({ hard: rawLine.hard === undefined ? true : Boolean(rawLine.hard),
|
|
137
|
+
runs: runs });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
while (out.length && !out[out.length - 1].runs.length) out.pop();
|
|
141
|
+
if (!out.length) return FigureRichText.fromPlain("");
|
|
142
|
+
// Nothing sits above the first line for it to have wrapped from, so its
|
|
143
|
+
// break is the author's by definition. A `hard: false` first line would
|
|
144
|
+
// make a re-wrap try to join it to the line before it.
|
|
145
|
+
out[0].hard = true;
|
|
146
|
+
return { lines: out };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Lines from a flat string -- the path every pre-`rich` document takes. */
|
|
150
|
+
static fromPlain(flat) {
|
|
151
|
+
const text = FigureRichText.normalizeBreaks(
|
|
152
|
+
typeof flat === "string" ? flat : "");
|
|
153
|
+
const out = text.split("\n").slice(0, FigureRichText.MAX_TEXT_LINES)
|
|
154
|
+
.map((line) => {
|
|
155
|
+
const cleaned = FigureRichText.cleanRunText(
|
|
156
|
+
line, FigureRichText.MAX_TEXT_LENGTH);
|
|
157
|
+
return { hard: true, runs: cleaned ? [{ text: cleaned }] : [] };
|
|
158
|
+
});
|
|
159
|
+
return { lines: out.length ? out : [{ hard: true, runs: [] }] };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* One styled span, or null if there is nothing left of it.
|
|
164
|
+
*
|
|
165
|
+
* A run carries only what it OVERRIDES. An absent `size_pt` means "whatever
|
|
166
|
+
* the box says", so raising the box's font size still reaches every run the
|
|
167
|
+
* user never touched.
|
|
168
|
+
*/
|
|
169
|
+
static normalizeRun(raw, budget) {
|
|
170
|
+
if (!raw || typeof raw !== "object") return null;
|
|
171
|
+
const text = FigureRichText.cleanRunText(raw.text, budget);
|
|
172
|
+
if (!text) return null;
|
|
173
|
+
const run = { text: text };
|
|
174
|
+
for (const mark of ["bold", "italic", "underline", "strike"]) {
|
|
175
|
+
if (raw[mark]) run[mark] = true;
|
|
176
|
+
}
|
|
177
|
+
const family = FigureRichText.cleanText(raw.family, 20);
|
|
178
|
+
if (FigureRichText.FAMILIES.includes(family)) run.family = family;
|
|
179
|
+
if (typeof raw.size_pt === "number" && isFinite(raw.size_pt)) {
|
|
180
|
+
run.size_pt = Math.max(1, Math.min(200, raw.size_pt));
|
|
181
|
+
}
|
|
182
|
+
if (typeof raw.color === "string" && /^#[0-9a-fA-F]{6}$/.test(raw.color)) {
|
|
183
|
+
run.color = raw.color.toLowerCase();
|
|
184
|
+
}
|
|
185
|
+
return run;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Adjacent runs with identical marks become one.
|
|
190
|
+
*
|
|
191
|
+
* Load-bearing, not tidiness. A contenteditable emits a fresh span per
|
|
192
|
+
* keystroke, so without this a caption grows a run per character: the
|
|
193
|
+
* document balloons, the "did anything change?" check before a commit never
|
|
194
|
+
* fires because the shape differs every time, and this normaliser can no
|
|
195
|
+
* longer be compared with the server's for equality at all.
|
|
196
|
+
*/
|
|
197
|
+
static coalesce(runs) {
|
|
198
|
+
const out = [];
|
|
199
|
+
for (const run of runs) {
|
|
200
|
+
const last = out[out.length - 1];
|
|
201
|
+
if (last && FigureRichText.marksOf(last) === FigureRichText.marksOf(run)) {
|
|
202
|
+
last.text += run.text;
|
|
203
|
+
} else {
|
|
204
|
+
out.push(run);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* At most `cap` runs, keeping every word.
|
|
212
|
+
*
|
|
213
|
+
* The overflow folds into the last surviving run rather than being sliced
|
|
214
|
+
* off. A hundred distinctly styled spans on one line is already past
|
|
215
|
+
* anything a caption needs, but the words are still the user's, so the cap
|
|
216
|
+
* costs them the marks and never the sentence.
|
|
217
|
+
*/
|
|
218
|
+
static capRuns(runs, cap) {
|
|
219
|
+
if (cap <= 0) return [];
|
|
220
|
+
if (runs.length <= cap) return runs;
|
|
221
|
+
const kept = runs.slice(0, cap);
|
|
222
|
+
const tail = runs.slice(cap).map((run) => run.text).join("");
|
|
223
|
+
kept[kept.length - 1] = { ...kept[kept.length - 1],
|
|
224
|
+
text: kept[kept.length - 1].text + tail };
|
|
225
|
+
return kept;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
static marksOf(run) {
|
|
229
|
+
return JSON.stringify(FigureRichText.MARKS.map((key) =>
|
|
230
|
+
run[key] === undefined ? null : run[key]));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** CRLF and CR become LF, tabs become a space -- a Windows paste otherwise
|
|
234
|
+
* leaves a bare CR inside a run, where it is a line break the line list
|
|
235
|
+
* knows nothing about. */
|
|
236
|
+
static normalizeBreaks(value) {
|
|
237
|
+
return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " ");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Run text, capped -- and NOT trimmed. The spaces around a run are the
|
|
241
|
+
* gaps between it and its neighbours on the same line, so trimming closes
|
|
242
|
+
* up "Fig. 1a" + " DAPI" into "Fig. 1aDAPI". */
|
|
243
|
+
static cleanRunText(value, budget) {
|
|
244
|
+
if (value === null || value === undefined) return "";
|
|
245
|
+
const text = typeof value === "string" ? value : String(value);
|
|
246
|
+
// eslint-disable-next-line no-control-regex
|
|
247
|
+
return text.replace(/\t/g, " ").replace(/[\x00-\x1f\x7f]/g, "")
|
|
248
|
+
.slice(0, Math.max(0, budget));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The mirror of `schema.clean_text`: control characters out, trimmed,
|
|
252
|
+
* capped. Used for field values, never for run text. */
|
|
253
|
+
static cleanText(value, limit) {
|
|
254
|
+
if (value === null || value === undefined) return "";
|
|
255
|
+
const text = typeof value === "string" ? value : String(value);
|
|
256
|
+
// eslint-disable-next-line no-control-regex
|
|
257
|
+
return text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
|
|
258
|
+
.trim().slice(0, limit);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// -- reading -----------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
/** The flat string this reads as. The annotation's `text` field is this. */
|
|
264
|
+
static plainText(rich) {
|
|
265
|
+
const lines = rich && Array.isArray(rich.lines) ? rich.lines : null;
|
|
266
|
+
if (!lines) return "";
|
|
267
|
+
return lines.map((line) => line.runs.map((run) => run.text).join(""))
|
|
268
|
+
.join("\n").slice(0, FigureRichText.MAX_TEXT_LENGTH);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Whether two rich texts are the same. Only meaningful because both sides
|
|
272
|
+
* are coalesced into a canonical form first -- this is what lets a commit
|
|
273
|
+
* skip a no-op edit instead of writing a new revision per keystroke. */
|
|
274
|
+
static equals(a, b) {
|
|
275
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Every run's resolved style, for a box whose defaults are `style`. */
|
|
279
|
+
static resolveRun(run, style) {
|
|
280
|
+
return {
|
|
281
|
+
text: run.text,
|
|
282
|
+
family: FigureRichText.family(run.family || style.font_family),
|
|
283
|
+
size_pt: run.size_pt === undefined ? style.font_size_pt : run.size_pt,
|
|
284
|
+
bold: Boolean(run.bold),
|
|
285
|
+
italic: Boolean(run.italic),
|
|
286
|
+
underline: Boolean(run.underline),
|
|
287
|
+
strike: Boolean(run.strike),
|
|
288
|
+
color: run.color || style.color,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// -- editing -----------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Apply a format patch to the characters in [start, end).
|
|
296
|
+
*
|
|
297
|
+
* Offsets are over the PLAIN text, newlines included, so they map directly
|
|
298
|
+
* onto what a selection in the editor covers. Runs straddling either edge
|
|
299
|
+
* are split, the ones inside take the patch, and the result is coalesced --
|
|
300
|
+
* which is what stops "bold this word, then unbold it" leaving three runs
|
|
301
|
+
* behind where there was one.
|
|
302
|
+
*
|
|
303
|
+
* A patch value of null removes the override and returns that property to
|
|
304
|
+
* the box's; any other value sets it. This is the whole of partial-selection
|
|
305
|
+
* formatting, and it is pure, so the probe can test it without a browser.
|
|
306
|
+
*/
|
|
307
|
+
static applyToRange(rich, start, end, patch) {
|
|
308
|
+
if (end <= start) return rich;
|
|
309
|
+
let offset = 0;
|
|
310
|
+
const lines = rich.lines.map((line, index) => {
|
|
311
|
+
if (index > 0) offset += 1; // the "\n" before this line
|
|
312
|
+
const runs = [];
|
|
313
|
+
for (const run of line.runs) {
|
|
314
|
+
const from = offset;
|
|
315
|
+
const to = offset + run.text.length;
|
|
316
|
+
offset = to;
|
|
317
|
+
// Three pieces, any of which may be empty: before the range,
|
|
318
|
+
// inside it, after it.
|
|
319
|
+
const cutA = Math.min(Math.max(start, from), to);
|
|
320
|
+
const cutB = Math.min(Math.max(end, from), to);
|
|
321
|
+
if (cutA > from) {
|
|
322
|
+
runs.push(FigureRichText.orderRun(
|
|
323
|
+
{ ...run, text: run.text.slice(0, cutA - from) }));
|
|
324
|
+
}
|
|
325
|
+
if (cutB > cutA) {
|
|
326
|
+
runs.push(FigureRichText.patchRun(
|
|
327
|
+
{ ...run, text: run.text.slice(cutA - from, cutB - from) }, patch));
|
|
328
|
+
}
|
|
329
|
+
if (to > cutB) {
|
|
330
|
+
runs.push(FigureRichText.orderRun(
|
|
331
|
+
{ ...run, text: run.text.slice(cutB - from) }));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return { hard: line.hard, runs: FigureRichText.coalesce(runs) };
|
|
335
|
+
});
|
|
336
|
+
return { lines: lines };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
static patchRun(run, patch) {
|
|
340
|
+
const out = { ...run };
|
|
341
|
+
for (const key of Object.keys(patch)) {
|
|
342
|
+
if (patch[key] === null || patch[key] === false) delete out[key];
|
|
343
|
+
else out[key] = patch[key];
|
|
344
|
+
}
|
|
345
|
+
return FigureRichText.orderRun(out);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* A run with its keys in the canonical order: text, then MARKS.
|
|
350
|
+
*
|
|
351
|
+
* Key order carries no meaning to a reader, but it does to
|
|
352
|
+
* `JSON.stringify`, which is how `equals()` decides whether a commit is a
|
|
353
|
+
* no-op and how the parity test compares this normaliser with the server's.
|
|
354
|
+
* `schema._normalize_run` builds its dicts in exactly this order, so
|
|
355
|
+
* matching it here means the two produce byte-identical JSON rather than
|
|
356
|
+
* merely equivalent JSON.
|
|
357
|
+
*/
|
|
358
|
+
static orderRun(run) {
|
|
359
|
+
const out = { text: run.text };
|
|
360
|
+
for (const key of FigureRichText.MARKS) {
|
|
361
|
+
if (run[key] !== undefined) out[key] = run[key];
|
|
362
|
+
}
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** What [start, end) currently looks like: a value where every run in the
|
|
367
|
+
* range agrees, and null where they do not. That null is what the sidebar
|
|
368
|
+
* renders as an indeterminate control. */
|
|
369
|
+
static formatOfRange(rich, start, end, style) {
|
|
370
|
+
const runs = FigureRichText.runsInRange(rich, start, end);
|
|
371
|
+
if (!runs.length) return null;
|
|
372
|
+
const first = FigureRichText.resolveRun(runs[0], style);
|
|
373
|
+
const out = { ...first };
|
|
374
|
+
for (const run of runs.slice(1)) {
|
|
375
|
+
const resolved = FigureRichText.resolveRun(run, style);
|
|
376
|
+
for (const key of Object.keys(out)) {
|
|
377
|
+
if (out[key] !== resolved[key]) out[key] = null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
delete out.text;
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
static runsInRange(rich, start, end) {
|
|
385
|
+
let offset = 0;
|
|
386
|
+
const hits = [];
|
|
387
|
+
rich.lines.forEach((line, index) => {
|
|
388
|
+
if (index > 0) offset += 1;
|
|
389
|
+
for (const run of line.runs) {
|
|
390
|
+
const from = offset;
|
|
391
|
+
const to = offset + run.text.length;
|
|
392
|
+
offset = to;
|
|
393
|
+
// A caret (start === end) reports the run it sits inside, which
|
|
394
|
+
// is what makes typing pick up the formatting to its left.
|
|
395
|
+
if (start === end ? (from < start && start <= to) : (from < end && to > start)) {
|
|
396
|
+
hits.push(run);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
return hits;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// -- wrapping ----------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Re-break the soft lines to fit `widthMm`, leaving the author's alone.
|
|
407
|
+
*
|
|
408
|
+
* Only the browser can measure a string, so only the browser can decide
|
|
409
|
+
* where a line breaks -- which is why the break it chooses is STORED rather
|
|
410
|
+
* than recomputed by an exporter that would have to guess. `hard` is what
|
|
411
|
+
* keeps the two kinds apart: a line the author ended with Enter is a
|
|
412
|
+
* paragraph boundary and is never joined to its neighbour, while a line the
|
|
413
|
+
* wrap produced may be rewritten freely.
|
|
414
|
+
*
|
|
415
|
+
* `measure(text, resolvedRun)` returns millimetres and is injected, both
|
|
416
|
+
* because a node probe has no text engine and because it keeps this
|
|
417
|
+
* function pure.
|
|
418
|
+
*/
|
|
419
|
+
static rewrap(rich, widthMm, style, measure) {
|
|
420
|
+
const out = [];
|
|
421
|
+
for (const paragraph of FigureRichText.paragraphs(rich)) {
|
|
422
|
+
const lines = FigureRichText.breakRuns(paragraph, widthMm, style, measure);
|
|
423
|
+
lines.forEach((runs, index) => {
|
|
424
|
+
out.push({ hard: index === 0, runs: FigureRichText.coalesce(runs) });
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
if (!out.length) return { lines: [{ hard: true, runs: [] }] };
|
|
428
|
+
out[0].hard = true;
|
|
429
|
+
return { lines: out };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** The runs of each paragraph, soft breaks dissolved. A soft break was the
|
|
433
|
+
* wrap's own decision, so it carries no content and is simply removed;
|
|
434
|
+
* joining across it is what lets a widened box reflow. */
|
|
435
|
+
static paragraphs(rich) {
|
|
436
|
+
const out = [];
|
|
437
|
+
rich.lines.forEach((line, index) => {
|
|
438
|
+
if (index === 0 || line.hard) out.push([]);
|
|
439
|
+
out[out.length - 1].push(...line.runs.map((run) => ({ ...run })));
|
|
440
|
+
});
|
|
441
|
+
return out.length ? out : [[]];
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Greedy line breaking over one paragraph's runs.
|
|
446
|
+
*
|
|
447
|
+
* The whitespace a break falls on STAYS on the line it broke from, and that
|
|
448
|
+
* is the whole reason a re-wrap is reversible. Dropping it -- which reads as
|
|
449
|
+
* the tidy thing to do, since it renders as nothing at the end of a line --
|
|
450
|
+
* means the stored lines no longer concatenate back into what the author
|
|
451
|
+
* typed: widening the box then produces "aaabbbcccddd". Because the lines
|
|
452
|
+
* ARE the document here, a space consumed at layout time is a space deleted
|
|
453
|
+
* from the caption.
|
|
454
|
+
*
|
|
455
|
+
* Trailing whitespace is invisible left-aligned and is excluded from the
|
|
456
|
+
* line width when the layout centres or right-aligns, so keeping it costs
|
|
457
|
+
* nothing on the page.
|
|
458
|
+
*/
|
|
459
|
+
static breakRuns(runs, widthMm, style, measure) {
|
|
460
|
+
const atoms = FigureRichText.atoms(runs);
|
|
461
|
+
if (!atoms.length) return [[]];
|
|
462
|
+
const lines = [];
|
|
463
|
+
let line = [];
|
|
464
|
+
let width = 0;
|
|
465
|
+
for (const atom of atoms) {
|
|
466
|
+
const w = measure(atom.text, FigureRichText.resolveRun(atom.run, style));
|
|
467
|
+
// A word that cannot fit an empty line is not broken: splitting a
|
|
468
|
+
// gene name or an accession number mid-token is worse than a line
|
|
469
|
+
// that overhangs, and an overhang is visible where a silent split
|
|
470
|
+
// reads as the real name.
|
|
471
|
+
if (!atom.space && line.length && width + w > widthMm) {
|
|
472
|
+
lines.push(line);
|
|
473
|
+
line = [];
|
|
474
|
+
width = 0;
|
|
475
|
+
}
|
|
476
|
+
line.push(atom);
|
|
477
|
+
width += w;
|
|
478
|
+
}
|
|
479
|
+
lines.push(line);
|
|
480
|
+
return lines.map((onLine) => onLine.map((a) => ({ ...a.run, text: a.text })));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Runs split into words and the whitespace between them, each remembering
|
|
484
|
+
* which run it came from so a break never loses a mark. */
|
|
485
|
+
static atoms(runs) {
|
|
486
|
+
const out = [];
|
|
487
|
+
for (const run of runs) {
|
|
488
|
+
for (const piece of run.text.split(/(\s+)/)) {
|
|
489
|
+
if (!piece) continue;
|
|
490
|
+
out.push({ text: piece, run: run, space: /^\s+$/.test(piece) });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return out;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// -- reading the editor's DOM back ------------------------------------
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Lines of runs, read back out of a contenteditable.
|
|
500
|
+
*
|
|
501
|
+
* The editor renders its DOM FROM the model, so most of what is here was
|
|
502
|
+
* put here by `elementFromRich`. What this has to survive is the part the
|
|
503
|
+
* browser contributes: typing merges text into a span, Backspace at the
|
|
504
|
+
* start of a line dissolves a line element, and some engines leave a stray
|
|
505
|
+
* <br> or an unwrapped text node behind. Rather than trusting any of that
|
|
506
|
+
* shape, this walks the tree and asks each text node what marks it has
|
|
507
|
+
* INHERITED, which is true whatever produced the nesting.
|
|
508
|
+
*
|
|
509
|
+
* Works on a minimal node interface -- `nodeType`, `nodeName`, `childNodes`,
|
|
510
|
+
* `nodeValue`, `dataset`, `style` -- so a probe can drive it with plain
|
|
511
|
+
* objects and no browser.
|
|
512
|
+
*/
|
|
513
|
+
static linesFromDom(root) {
|
|
514
|
+
const lines = [];
|
|
515
|
+
let current = [];
|
|
516
|
+
const flush = () => { lines.push(current); current = []; };
|
|
517
|
+
|
|
518
|
+
const walk = (node, inherited) => {
|
|
519
|
+
// Text
|
|
520
|
+
if (node.nodeType === 3) {
|
|
521
|
+
const text = FigureRichText.cleanRunText(
|
|
522
|
+
node.nodeValue || "", FigureRichText.MAX_TEXT_LENGTH);
|
|
523
|
+
if (text) current.push(FigureRichText.orderRun({ ...inherited, text: text }));
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (node.nodeType !== 1) return;
|
|
527
|
+
const name = (node.nodeName || "").toUpperCase();
|
|
528
|
+
if (name === "BR") { flush(); return; }
|
|
529
|
+
|
|
530
|
+
const marks = FigureRichText.marksFromNode(node, inherited);
|
|
531
|
+
const block = name === "DIV" || name === "P";
|
|
532
|
+
// A block element starts a line unless it is the first thing here,
|
|
533
|
+
// in which case it IS the line already open.
|
|
534
|
+
if (block && current.length) flush();
|
|
535
|
+
for (const child of Array.from(node.childNodes || [])) walk(child, marks);
|
|
536
|
+
if (block) flush();
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
for (const child of Array.from(root.childNodes || [])) walk(child, {});
|
|
540
|
+
if (current.length) flush();
|
|
541
|
+
|
|
542
|
+
const out = lines.map((runs) => ({
|
|
543
|
+
hard: true, runs: FigureRichText.coalesce(runs),
|
|
544
|
+
}));
|
|
545
|
+
while (out.length > 1 && !out[out.length - 1].runs.length) out.pop();
|
|
546
|
+
return { lines: out.length ? out : [{ hard: true, runs: [] }] };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** What one element adds to the marks its children inherit. */
|
|
550
|
+
static marksFromNode(node, inherited) {
|
|
551
|
+
const marks = { ...inherited };
|
|
552
|
+
const name = (node.nodeName || "").toUpperCase();
|
|
553
|
+
const style = node.style || {};
|
|
554
|
+
const data = node.dataset || {};
|
|
555
|
+
|
|
556
|
+
if (name === "B" || name === "STRONG") marks.bold = true;
|
|
557
|
+
if (name === "I" || name === "EM") marks.italic = true;
|
|
558
|
+
if (name === "U") marks.underline = true;
|
|
559
|
+
if (name === "S" || name === "STRIKE" || name === "DEL") marks.strike = true;
|
|
560
|
+
|
|
561
|
+
const weight = style.fontWeight;
|
|
562
|
+
if (weight === "bold" || weight === "bolder" || parseInt(weight, 10) >= 600) {
|
|
563
|
+
marks.bold = true;
|
|
564
|
+
}
|
|
565
|
+
if (style.fontStyle === "italic" || style.fontStyle === "oblique") marks.italic = true;
|
|
566
|
+
|
|
567
|
+
// The editor stamps its own marks as data attributes rather than
|
|
568
|
+
// reading them back out of CSS: `font-family` comes back from the
|
|
569
|
+
// browser as the whole resolved stack, and matching a family name
|
|
570
|
+
// against that is a parser nobody should have to own.
|
|
571
|
+
if (data.bold === "1") marks.bold = true;
|
|
572
|
+
if (data.italic === "1") marks.italic = true;
|
|
573
|
+
if (data.underline === "1") marks.underline = true;
|
|
574
|
+
if (data.strike === "1") marks.strike = true;
|
|
575
|
+
if (FigureRichText.FAMILIES.includes(data.family)) marks.family = data.family;
|
|
576
|
+
if (data.size) {
|
|
577
|
+
const size = parseFloat(data.size);
|
|
578
|
+
if (isFinite(size)) marks.size_pt = Math.max(1, Math.min(200, size));
|
|
579
|
+
}
|
|
580
|
+
if (data.color && /^#[0-9a-fA-F]{6}$/.test(data.color)) {
|
|
581
|
+
marks.color = data.color.toLowerCase();
|
|
582
|
+
}
|
|
583
|
+
return marks;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** The marks a run carries, as the editor's data attributes. */
|
|
587
|
+
static runAttributes(run) {
|
|
588
|
+
const parts = [];
|
|
589
|
+
if (run.bold) parts.push('data-bold="1"');
|
|
590
|
+
if (run.italic) parts.push('data-italic="1"');
|
|
591
|
+
if (run.underline) parts.push('data-underline="1"');
|
|
592
|
+
if (run.strike) parts.push('data-strike="1"');
|
|
593
|
+
if (run.family) parts.push(`data-family="${run.family}"`);
|
|
594
|
+
if (run.size_pt !== undefined) parts.push(`data-size="${run.size_pt}"`);
|
|
595
|
+
if (run.color) parts.push(`data-color="${run.color}"`);
|
|
596
|
+
return parts.join(" ");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// -- measuring ---------------------------------------------------------
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Height and baseline offset of the line box holding these runs.
|
|
603
|
+
*
|
|
604
|
+
* The tallest run sets the box and the line is centred in it, half the
|
|
605
|
+
* leading above and half below, so a line mixing an 8 pt caption with a 6 pt
|
|
606
|
+
* superscript sits where a reader expects rather than riding the bottom of
|
|
607
|
+
* its box. Mirrors `textmetrics.line_metrics`.
|
|
608
|
+
*/
|
|
609
|
+
static lineMetrics(runs, style) {
|
|
610
|
+
const leading = style.line_height || FigureRichText.LINE_HEIGHT;
|
|
611
|
+
if (!runs.length) {
|
|
612
|
+
const em = style.font_size_pt * FigureRichText.MM_PER_PT;
|
|
613
|
+
const family = FigureRichText.family(style.font_family);
|
|
614
|
+
// An empty line still occupies one: it is the blank line between
|
|
615
|
+
// two paragraphs, and collapsing it to nothing would close the gap
|
|
616
|
+
// the author put there.
|
|
617
|
+
return { lead: em * leading,
|
|
618
|
+
ascent: em * FigureRichText.ASCENT[family],
|
|
619
|
+
descent: em * FigureRichText.DESCENT[family] };
|
|
620
|
+
}
|
|
621
|
+
let tallest = runs[0];
|
|
622
|
+
for (const run of runs) {
|
|
623
|
+
const size = run.size_pt === undefined ? style.font_size_pt : run.size_pt;
|
|
624
|
+
const best = tallest.size_pt === undefined ? style.font_size_pt : tallest.size_pt;
|
|
625
|
+
if (size > best) tallest = run;
|
|
626
|
+
}
|
|
627
|
+
const size = tallest.size_pt === undefined ? style.font_size_pt : tallest.size_pt;
|
|
628
|
+
const family = FigureRichText.family(tallest.family || style.font_family);
|
|
629
|
+
const em = size * FigureRichText.MM_PER_PT;
|
|
630
|
+
return { lead: em * leading,
|
|
631
|
+
ascent: em * FigureRichText.ASCENT[family],
|
|
632
|
+
descent: em * FigureRichText.DESCENT[family] };
|
|
633
|
+
}
|
|
634
|
+
}
|