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,784 @@
|
|
|
1
|
+
"""Writing a figure out: PDF, PNG, TIFF.
|
|
2
|
+
|
|
3
|
+
One layout (`compose.page_instructions`) rendered by two backends, so the two
|
|
4
|
+
formats cannot disagree about where a label sits. The split between them is the
|
|
5
|
+
one that matters for a publication:
|
|
6
|
+
|
|
7
|
+
PDF microscopy panels are rasters; text, labels, legends, scale bars,
|
|
8
|
+
arrows and shapes are real vector objects. That is what makes the
|
|
9
|
+
file editable in Illustrator without being a multi-gigabyte attempt
|
|
10
|
+
to turn half a million cells into polygons (spec §51).
|
|
11
|
+
PNG everything flattened. For circulating a draft.
|
|
12
|
+
TIFF everything flattened, at the requested DPI, for submission.
|
|
13
|
+
|
|
14
|
+
Every panel is re-rendered from the source image at the size the page asks for.
|
|
15
|
+
That is the whole point: a panel captured at 300 screen pixels comes out at
|
|
16
|
+
however many the DPI demands, and the quality of the export has nothing to do
|
|
17
|
+
with the monitor it was laid out on.
|
|
18
|
+
|
|
19
|
+
`reportlab` is a hard dependency, but still imported lazily: an environment that
|
|
20
|
+
somehow lost it can still export PNG and TIFF, and asking for a PDF says what to
|
|
21
|
+
reinstall rather than failing with an ImportError from three frames down.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from plexora.plugins.figure_builder.server import (
|
|
30
|
+
compose, provenance, render, repository, schema, textmetrics)
|
|
31
|
+
|
|
32
|
+
#: Formats this can write. Ordered as the dialog offers them.
|
|
33
|
+
FORMATS = ("pdf", "png", "tiff")
|
|
34
|
+
|
|
35
|
+
#: The DPI a page's own furniture is rasterised at for PNG/TIFF. Panels are
|
|
36
|
+
#: rendered at the requested DPI regardless; this is the resolution text and
|
|
37
|
+
#: rules land at.
|
|
38
|
+
RASTER_MIN_DPI = 72
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ExportUnavailable(Exception):
|
|
42
|
+
"""A format this build cannot write, with the install line to fix it."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def export(document, out_dir, options, progress=None, cancelled=None):
|
|
46
|
+
"""Write a figure and return a report.
|
|
47
|
+
|
|
48
|
+
`progress(done, total, message)` is called between panels and `cancelled()`
|
|
49
|
+
is checked at the same points -- between panels rather than inside one,
|
|
50
|
+
because a panel is the smallest unit that leaves a coherent file behind and
|
|
51
|
+
tearing out of the middle of one buys milliseconds.
|
|
52
|
+
"""
|
|
53
|
+
fmt = options.get("format", "pdf")
|
|
54
|
+
if fmt not in FORMATS:
|
|
55
|
+
raise ValueError(f"unknown export format {fmt!r}")
|
|
56
|
+
dpi = int(options.get("dpi") or document["settings"]["dpi_default"])
|
|
57
|
+
|
|
58
|
+
out_dir = Path(out_dir)
|
|
59
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
stem = _safe_stem(document["title"]) or document["figure_id"]
|
|
61
|
+
|
|
62
|
+
pages = _pages(document)
|
|
63
|
+
total = sum(len(page["panels"]) for page in pages) or 1
|
|
64
|
+
done = 0
|
|
65
|
+
results = []
|
|
66
|
+
|
|
67
|
+
# One handle per datasource, held across the whole export: a figure is
|
|
68
|
+
# routinely eight panels from one slide, and reopening a pyramidal TIFF
|
|
69
|
+
# eight times is eight directory walks for the same answer.
|
|
70
|
+
sources = {}
|
|
71
|
+
try:
|
|
72
|
+
rendered_pages = []
|
|
73
|
+
for page in pages:
|
|
74
|
+
drawn = {}
|
|
75
|
+
for panel in page["panels"]:
|
|
76
|
+
if cancelled and cancelled():
|
|
77
|
+
return {"cancelled": True}
|
|
78
|
+
if progress:
|
|
79
|
+
progress(done, total, f"Rendering panel {done + 1} of {total}")
|
|
80
|
+
image, result = _render_one(document, panel, sources, dpi)
|
|
81
|
+
drawn[panel["panel_id"]] = image
|
|
82
|
+
results.append((panel, result))
|
|
83
|
+
done += 1
|
|
84
|
+
rendered_pages.append({**page, "images": drawn})
|
|
85
|
+
|
|
86
|
+
if progress:
|
|
87
|
+
progress(done, total, "Building provenance")
|
|
88
|
+
manifest = provenance.manifest(document, results, {**options, "dpi": dpi})
|
|
89
|
+
report_lines = provenance.lines(document, results, {**options, "dpi": dpi})
|
|
90
|
+
|
|
91
|
+
if progress:
|
|
92
|
+
progress(done, total, f"Writing {fmt.upper()}")
|
|
93
|
+
if fmt == "pdf":
|
|
94
|
+
path = out_dir / f"{stem}.pdf"
|
|
95
|
+
fonts = _write_pdf(document, rendered_pages, path, dpi, report_lines,
|
|
96
|
+
include_provenance=options.get("provenance", True))
|
|
97
|
+
written = [path]
|
|
98
|
+
else:
|
|
99
|
+
written, fonts = _write_rasters(
|
|
100
|
+
document, rendered_pages, out_dir, stem, fmt, dpi)
|
|
101
|
+
|
|
102
|
+
manifest_path = out_dir / f"{stem}-provenance.json"
|
|
103
|
+
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
104
|
+
finally:
|
|
105
|
+
# `None` is cached for a source that could not be opened, so that one
|
|
106
|
+
# missing image is not re-attempted once per panel. Skipped here rather
|
|
107
|
+
# than not cached, because the retry is the expensive half.
|
|
108
|
+
for source in sources.values():
|
|
109
|
+
if source is not None:
|
|
110
|
+
source.close()
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
"cancelled": False,
|
|
114
|
+
"files": [str(path) for path in written] + [str(manifest_path)],
|
|
115
|
+
"download": str(written[0]),
|
|
116
|
+
"panels": len(results),
|
|
117
|
+
"warnings": _warnings(results, fonts),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def preflight(document, options):
|
|
122
|
+
"""What the user should know before committing to an export.
|
|
123
|
+
|
|
124
|
+
Opens each source and asks it, so "this panel is 96 DPI at that size" and
|
|
125
|
+
"this channel is gone" are answered before a single pixel is rendered
|
|
126
|
+
rather than in a warning after the file is written.
|
|
127
|
+
"""
|
|
128
|
+
dpi = int(options.get("dpi") or document["settings"]["dpi_default"])
|
|
129
|
+
sources = {}
|
|
130
|
+
reports = []
|
|
131
|
+
try:
|
|
132
|
+
for page in _pages(document):
|
|
133
|
+
for panel in page["panels"]:
|
|
134
|
+
origin = document["sources"].get(panel["source_id"]) or {}
|
|
135
|
+
if origin.get("kind") == "imported_asset":
|
|
136
|
+
# Nothing to open and nothing to reconstruct: the file IS
|
|
137
|
+
# the panel. Its effective resolution is still worth
|
|
138
|
+
# reporting, because enlarging a 400-pixel schematic onto a
|
|
139
|
+
# third of a page is the same soft result as enlarging a
|
|
140
|
+
# crop.
|
|
141
|
+
reports.append((panel, _imported_report(origin, panel, dpi)))
|
|
142
|
+
continue
|
|
143
|
+
source = _source_for(document, panel, sources)
|
|
144
|
+
if source is None:
|
|
145
|
+
reports.append((panel, {"missing_source": True,
|
|
146
|
+
"missing_channels": [], "missing_overlays": []}))
|
|
147
|
+
continue
|
|
148
|
+
reports.append((panel, render.panel_report(
|
|
149
|
+
source, panel["scene"], panel["placement"]["w_mm"], dpi)))
|
|
150
|
+
finally:
|
|
151
|
+
for source in sources.values():
|
|
152
|
+
if source is not None:
|
|
153
|
+
source.close()
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
"dpi": dpi,
|
|
157
|
+
"panels": len(reports),
|
|
158
|
+
"warnings": _warnings(reports),
|
|
159
|
+
"per_panel": [
|
|
160
|
+
{"panel_id": panel["panel_id"], **report} for panel, report in reports
|
|
161
|
+
],
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# -- rendering -----------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _pages(document):
|
|
169
|
+
from plexora.plugins.figure_builder.server import schema # noqa: F401 (shape only)
|
|
170
|
+
|
|
171
|
+
pages = []
|
|
172
|
+
for page in document["pages"]:
|
|
173
|
+
panels = [panel for panel in document["panels"].values()
|
|
174
|
+
if panel["placement"] and panel["placement"]["page_id"] == page["page_id"]]
|
|
175
|
+
annotations = [a for a in document["annotations"].values()
|
|
176
|
+
if a["page_id"] == page["page_id"]]
|
|
177
|
+
pages.append({"page": page, "panels": panels, "annotations": annotations})
|
|
178
|
+
return pages
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _source_for(document, panel, cache):
|
|
182
|
+
source = document["sources"].get(panel["source_id"])
|
|
183
|
+
if not source or source["kind"] != "plexora_project" or not source["datasource"]:
|
|
184
|
+
return None
|
|
185
|
+
name = source["datasource"]
|
|
186
|
+
if name not in cache:
|
|
187
|
+
try:
|
|
188
|
+
cache[name] = render.SourceImage(name)
|
|
189
|
+
except (KeyError, render.RenderError, OSError):
|
|
190
|
+
cache[name] = None
|
|
191
|
+
return cache[name]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _imported_report(source, panel, dpi):
|
|
195
|
+
"""What is worth saying about an imported panel.
|
|
196
|
+
|
|
197
|
+
Same shape as `render.panel_report`, so the warning machinery does not have
|
|
198
|
+
to know which kind of source a panel came from. There are no channels and no
|
|
199
|
+
overlays; what there is is a resolution, and enlarging a 400-pixel schematic
|
|
200
|
+
onto a third of a page is exactly as soft as enlarging a crop.
|
|
201
|
+
"""
|
|
202
|
+
width_px = (source.get("image") or {}).get("width") or 0
|
|
203
|
+
return {
|
|
204
|
+
"missing_channels": [], "missing_overlays": [],
|
|
205
|
+
"effective_dpi": round(render.effective_dpi(width_px, panel["placement"]["w_mm"]), 1),
|
|
206
|
+
"requested_dpi": dpi,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _render_imported(document, panel, figure_id, width_px, height_px):
|
|
211
|
+
"""An imported PNG, JPEG or TIFF, resampled to the page.
|
|
212
|
+
|
|
213
|
+
Read from the figure's own directory rather than re-rendered: for a
|
|
214
|
+
schematic or a supporting RGB image the file IS the panel, and there is
|
|
215
|
+
nothing to reconstruct it from at a higher resolution than it already has.
|
|
216
|
+
It is enlarged if the page asks for more, and the preflight says so.
|
|
217
|
+
"""
|
|
218
|
+
from PIL import Image
|
|
219
|
+
|
|
220
|
+
source = document["sources"].get(panel["source_id"]) or {}
|
|
221
|
+
path = repository.asset_path(figure_id, source.get("asset_id") or "")
|
|
222
|
+
if path is None:
|
|
223
|
+
return None
|
|
224
|
+
with Image.open(path) as opened:
|
|
225
|
+
return opened.convert("RGB").resize((width_px, height_px), Image.LANCZOS)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _render_one(document, panel, sources, dpi):
|
|
229
|
+
from PIL import Image
|
|
230
|
+
|
|
231
|
+
width_px, height_px = compose.panel_pixels(
|
|
232
|
+
panel["placement"]["w_mm"], panel["placement"]["h_mm"], dpi)
|
|
233
|
+
|
|
234
|
+
origin = document["sources"].get(panel["source_id"]) or {}
|
|
235
|
+
if origin.get("kind") == "imported_asset":
|
|
236
|
+
image = _render_imported(document, panel, document["figure_id"], width_px, height_px)
|
|
237
|
+
if image is not None:
|
|
238
|
+
return image, {**_imported_report(origin, panel, dpi), "imported": True}
|
|
239
|
+
return (Image.new("RGB", (width_px, height_px), "black"),
|
|
240
|
+
{"missing_source": True, "missing_channels": [], "missing_overlays": [],
|
|
241
|
+
"effective_dpi": 0, "requested_dpi": dpi})
|
|
242
|
+
|
|
243
|
+
source = _source_for(document, panel, sources)
|
|
244
|
+
if source is None:
|
|
245
|
+
# The source is gone. A black panel rather than a hole, and the
|
|
246
|
+
# provenance says which panel and why -- an export that silently
|
|
247
|
+
# dropped it would be a figure with a missing field nobody noticed.
|
|
248
|
+
return (Image.new("RGB", (width_px, height_px), "black"),
|
|
249
|
+
{"missing_source": True, "missing_channels": [], "missing_overlays": [],
|
|
250
|
+
"effective_dpi": 0, "requested_dpi": dpi})
|
|
251
|
+
|
|
252
|
+
report = render.panel_report(source, panel["scene"], panel["placement"]["w_mm"], dpi)
|
|
253
|
+
image, detail = render.render_panel(source, panel["scene"], width_px, height_px)
|
|
254
|
+
return image, {**report, **detail}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _warnings(results, fonts=()):
|
|
258
|
+
"""The whole export's caveats, deduplicated and phrased for a person."""
|
|
259
|
+
out = []
|
|
260
|
+
if fonts:
|
|
261
|
+
# Named rather than omitted, like every other limitation this exporter
|
|
262
|
+
# reports. The layout is still right on the raster path -- Pillow's
|
|
263
|
+
# fallback measures and places a baseline correctly -- so the honest
|
|
264
|
+
# statement is about the typeface and not about the positions.
|
|
265
|
+
out.append({
|
|
266
|
+
"kind": "substituted_font", "fonts": sorted(fonts),
|
|
267
|
+
"message": "Some text was drawn in a substitute typeface ("
|
|
268
|
+
+ ", ".join(sorted(fonts))
|
|
269
|
+
+ " was not available). The PDF is the typographic master.",
|
|
270
|
+
})
|
|
271
|
+
low = [panel["panel_id"] for panel, report in results
|
|
272
|
+
if report.get("effective_dpi") and report.get("requested_dpi")
|
|
273
|
+
and report["effective_dpi"] < report["requested_dpi"] * 0.75]
|
|
274
|
+
if low:
|
|
275
|
+
out.append({
|
|
276
|
+
"kind": "low_resolution", "panels": low,
|
|
277
|
+
"message": "Some panels are enlarged past what their source holds. They "
|
|
278
|
+
"will export, but they cannot become sharper than the image is.",
|
|
279
|
+
})
|
|
280
|
+
missing_source = [panel["panel_id"] for panel, report in results
|
|
281
|
+
if report.get("missing_source")]
|
|
282
|
+
if missing_source:
|
|
283
|
+
out.append({"kind": "missing_source", "panels": missing_source,
|
|
284
|
+
"message": "Some panels' images are no longer available and export black."})
|
|
285
|
+
overlays = sorted({name for _, report in results
|
|
286
|
+
for name in report.get("missing_overlays") or []})
|
|
287
|
+
if overlays:
|
|
288
|
+
out.append({
|
|
289
|
+
"kind": "overlays_not_rendered", "panels": overlays,
|
|
290
|
+
"message": "Overlays drawn by a tool (" + ", ".join(overlays) + ") are not "
|
|
291
|
+
"re-rendered at export resolution. The provenance page says so.",
|
|
292
|
+
})
|
|
293
|
+
return out
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# -- PDF -----------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _write_pdf(document, pages, path, dpi, report_lines, include_provenance=True):
|
|
300
|
+
try:
|
|
301
|
+
from reportlab.lib.units import mm
|
|
302
|
+
from reportlab.lib.utils import ImageReader
|
|
303
|
+
from reportlab.pdfgen import canvas as pdf_canvas
|
|
304
|
+
except ImportError as exc: # pragma: no cover - depends on the environment
|
|
305
|
+
raise ExportUnavailable(
|
|
306
|
+
"PDF export needs reportlab. Install it with: pip install reportlab"
|
|
307
|
+
) from exc
|
|
308
|
+
|
|
309
|
+
pdf = pdf_canvas.Canvas(str(path))
|
|
310
|
+
pdf.setTitle(document["title"])
|
|
311
|
+
pdf.setSubject(f"Plexora figure {document['figure_id']} revision {document['revision']}")
|
|
312
|
+
# Fonts this export could not honour, collected as it goes and stated on the
|
|
313
|
+
# provenance page rather than silently substituted.
|
|
314
|
+
notes = set()
|
|
315
|
+
|
|
316
|
+
for entry in pages:
|
|
317
|
+
page = entry["page"]
|
|
318
|
+
width_mm, height_mm = page["size_mm"]["w"], page["size_mm"]["h"]
|
|
319
|
+
pdf.setPageSize((width_mm * mm, height_mm * mm))
|
|
320
|
+
_fill(pdf, page["background"], 0, 0, width_mm, height_mm, height_mm, mm)
|
|
321
|
+
|
|
322
|
+
for item in compose.page_instructions(document, page, entry["panels"], entry["annotations"]):
|
|
323
|
+
_draw_pdf(pdf, item, entry["images"], height_mm, notes, mm, ImageReader)
|
|
324
|
+
pdf.showPage()
|
|
325
|
+
|
|
326
|
+
if include_provenance:
|
|
327
|
+
_provenance_page(pdf, report_lines + _font_lines(notes), document, mm)
|
|
328
|
+
pdf.save()
|
|
329
|
+
return notes
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _font_lines(notes):
|
|
333
|
+
"""What the PDF could not set, for the provenance page.
|
|
334
|
+
|
|
335
|
+
Unreachable unless `textmetrics._POSTSCRIPT` has a hole in it, since the
|
|
336
|
+
families are whitelisted on the way into the document -- which is the point:
|
|
337
|
+
if it ever does fire, it says so on the artefact instead of shipping the
|
|
338
|
+
wrong typeface quietly.
|
|
339
|
+
"""
|
|
340
|
+
if not notes:
|
|
341
|
+
return []
|
|
342
|
+
return ["Fonts substituted: " + ", ".join(sorted(notes))]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _fill(pdf, colour, x, y, width, height, page_height, mm):
|
|
346
|
+
# A transparent page has no fill to draw. PDF supports transparency, but a
|
|
347
|
+
# figure with no page at all under it composites onto whatever the reader's
|
|
348
|
+
# viewer paints -- usually a grey app chrome -- so it is rendered white and
|
|
349
|
+
# the export dialog says which formats can carry the alpha.
|
|
350
|
+
if not colour or colour == schema.TRANSPARENT:
|
|
351
|
+
return
|
|
352
|
+
pdf.setFillColor(colour)
|
|
353
|
+
pdf.rect(x * mm, (page_height - y - height) * mm, width * mm, height * mm,
|
|
354
|
+
stroke=0, fill=1)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _pdf_font(run, notes):
|
|
358
|
+
"""The PostScript font for one run, or the default with a note saying so.
|
|
359
|
+
|
|
360
|
+
`postscript_name` raises rather than substituting quietly, because by the
|
|
361
|
+
time a run reaches here its family has been through the schema's whitelist:
|
|
362
|
+
a miss is a typo in the table or a family added to the UI and not to it.
|
|
363
|
+
The fallback is still right -- a missing font is a style problem and not a
|
|
364
|
+
reason to lose an export -- but it is recorded, and the provenance page
|
|
365
|
+
names it. Silently shipping the wrong typeface is the one option that is not
|
|
366
|
+
on the table.
|
|
367
|
+
"""
|
|
368
|
+
try:
|
|
369
|
+
return textmetrics.postscript_name(run["family"], run["bold"], run["italic"])
|
|
370
|
+
except KeyError:
|
|
371
|
+
notes.add(run["family"])
|
|
372
|
+
return textmetrics.postscript_name(
|
|
373
|
+
textmetrics.DEFAULT_FAMILY, run["bold"], run["italic"])
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _pdf_decorations(pdf, run, pen, baseline, width, mm):
|
|
377
|
+
"""Underline and strike-through, drawn rather than delegated.
|
|
378
|
+
|
|
379
|
+
A PDF has no underline of its own -- the mark is always a drawn rule -- and
|
|
380
|
+
the canvas draws its own for the same reason SVG's would be wrong: the
|
|
381
|
+
browser takes the position from whatever font file it actually resolved,
|
|
382
|
+
which is Arial where the PDF holds Helvetica. Both sides use the same
|
|
383
|
+
fraction of the em instead, so the rule lands in the same place.
|
|
384
|
+
"""
|
|
385
|
+
# reportlab's user space IS points, so one em is the size in points and
|
|
386
|
+
# needs no conversion. `mm` is only the multiplier for lengths arriving in
|
|
387
|
+
# millimetres.
|
|
388
|
+
em = run["size_pt"]
|
|
389
|
+
for on, offset in ((run["underline"], -textmetrics.UNDERLINE_OFFSET_EM),
|
|
390
|
+
(run["strike"], textmetrics.STRIKE_OFFSET_EM)):
|
|
391
|
+
if not on:
|
|
392
|
+
continue
|
|
393
|
+
thickness = em * textmetrics.UNDERLINE_THICKNESS_EM
|
|
394
|
+
pdf.setFillColor(run["color"] or "#000000")
|
|
395
|
+
pdf.rect(pen, baseline + em * offset, width, thickness, stroke=0, fill=1)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _draw_pdf(pdf, item, images, page_height, notes, mm, ImageReader):
|
|
399
|
+
# PDF's origin is bottom-left and the document's is top-left. Converted
|
|
400
|
+
# here, once, so nothing upstream has to hold two coordinate systems.
|
|
401
|
+
def top(y, height=0.0):
|
|
402
|
+
return (page_height - y - height) * mm
|
|
403
|
+
|
|
404
|
+
# A rotation is applied AROUND the finished layout, never inside it: the
|
|
405
|
+
# instruction's coordinates are square and this turns the page under them,
|
|
406
|
+
# about the box's centre. Same rule in the canvas and the raster writer, so
|
|
407
|
+
# a rotated caption cannot lay out differently from an upright one.
|
|
408
|
+
rotation = item.get("rotation")
|
|
409
|
+
if rotation:
|
|
410
|
+
pivot = item["pivot"]
|
|
411
|
+
pdf.saveState()
|
|
412
|
+
pdf.translate(pivot["x"] * mm, top(pivot["y"]))
|
|
413
|
+
pdf.rotate(-rotation)
|
|
414
|
+
pdf.translate(-pivot["x"] * mm, -top(pivot["y"]))
|
|
415
|
+
try:
|
|
416
|
+
_draw_pdf(pdf, {k: v for k, v in item.items() if k != "rotation"},
|
|
417
|
+
images, page_height, notes, mm, ImageReader)
|
|
418
|
+
finally:
|
|
419
|
+
pdf.restoreState()
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
kind = item["kind"]
|
|
423
|
+
if kind == "panel":
|
|
424
|
+
image = images.get(item["panel_id"])
|
|
425
|
+
if image is not None:
|
|
426
|
+
pdf.drawImage(ImageReader(image), item["x"] * mm, top(item["y"], item["h"]),
|
|
427
|
+
item["w"] * mm, item["h"] * mm, mask=None)
|
|
428
|
+
return
|
|
429
|
+
|
|
430
|
+
if kind == "text":
|
|
431
|
+
from reportlab.pdfbase import pdfmetrics
|
|
432
|
+
|
|
433
|
+
# Every position bar one is already decided: the instruction carries the
|
|
434
|
+
# baseline and the box to align in, and this walks a pen along the line.
|
|
435
|
+
# Advancing by the width of what it just drew is not a decision -- it is
|
|
436
|
+
# what drawing a string means -- so the two backends cannot disagree.
|
|
437
|
+
widths = [pdfmetrics.stringWidth(run["text"], _pdf_font(run, notes),
|
|
438
|
+
run["size_pt"]) for run in item["runs"]]
|
|
439
|
+
line_width = sum(widths)
|
|
440
|
+
# Trailing whitespace is excluded from the width so that a wrapped line
|
|
441
|
+
# centres on its words: the space the break landed on is kept in the run
|
|
442
|
+
# (it is what makes the break reversible) and would otherwise pull every
|
|
443
|
+
# centred line a little to the left.
|
|
444
|
+
if item["runs"] and item["runs"][-1]["text"] != item["runs"][-1]["text"].rstrip():
|
|
445
|
+
stripped = item["runs"][-1]["text"].rstrip()
|
|
446
|
+
line_width -= widths[-1] - pdfmetrics.stringWidth(
|
|
447
|
+
stripped, _pdf_font(item["runs"][-1], notes), item["runs"][-1]["size_pt"])
|
|
448
|
+
|
|
449
|
+
box_width = item.get("w", 0) * mm
|
|
450
|
+
align = item.get("align", "left")
|
|
451
|
+
if align == "center":
|
|
452
|
+
pen = item["x"] * mm + (box_width - line_width) / 2
|
|
453
|
+
elif align == "right":
|
|
454
|
+
pen = item["x"] * mm + box_width - line_width
|
|
455
|
+
else:
|
|
456
|
+
pen = item["x"] * mm
|
|
457
|
+
# Justify spreads the slack across the gaps between words rather than
|
|
458
|
+
# scaling anything, and never runs on the last line of a paragraph --
|
|
459
|
+
# `compose` has already turned that one into "left".
|
|
460
|
+
extra = 0.0
|
|
461
|
+
if align == "justify":
|
|
462
|
+
gaps = sum(run["text"].count(" ") for run in item["runs"])
|
|
463
|
+
extra = (box_width - line_width) / gaps if gaps and box_width > line_width else 0.0
|
|
464
|
+
|
|
465
|
+
baseline = top(item["y"])
|
|
466
|
+
for run, width in zip(item["runs"], widths):
|
|
467
|
+
font = _pdf_font(run, notes)
|
|
468
|
+
pdf.setFont(font, run["size_pt"])
|
|
469
|
+
pdf.setFillColor(run["color"] or "#000000")
|
|
470
|
+
spread = extra * run["text"].count(" ")
|
|
471
|
+
if extra:
|
|
472
|
+
pdf.drawString(pen, baseline, run["text"], wordSpace=extra)
|
|
473
|
+
else:
|
|
474
|
+
pdf.drawString(pen, baseline, run["text"])
|
|
475
|
+
_pdf_decorations(pdf, run, pen, baseline, width + spread, mm)
|
|
476
|
+
pen += width + spread
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
if kind == "swatch":
|
|
480
|
+
ramp = item.get("ramp")
|
|
481
|
+
if ramp:
|
|
482
|
+
step = item["w"] / max(1, len(ramp))
|
|
483
|
+
for index, colour in enumerate(ramp):
|
|
484
|
+
pdf.setFillColor(colour)
|
|
485
|
+
pdf.rect((item["x"] + index * step) * mm, top(item["y"], item["h"]),
|
|
486
|
+
step * mm, item["h"] * mm, stroke=0, fill=1)
|
|
487
|
+
else:
|
|
488
|
+
pdf.setFillColor(item["color"])
|
|
489
|
+
pdf.rect(item["x"] * mm, top(item["y"], item["h"]),
|
|
490
|
+
item["w"] * mm, item["h"] * mm, stroke=0, fill=1)
|
|
491
|
+
return
|
|
492
|
+
|
|
493
|
+
pdf.setLineWidth(item.get("line_width_pt", 0.75))
|
|
494
|
+
if item.get("stroke"):
|
|
495
|
+
pdf.setStrokeColor(item["stroke"])
|
|
496
|
+
if item.get("fill"):
|
|
497
|
+
pdf.setFillColor(item["fill"])
|
|
498
|
+
|
|
499
|
+
if kind == "rect":
|
|
500
|
+
pdf.rect(item["x"] * mm, top(item["y"], item["h"]), item["w"] * mm, item["h"] * mm,
|
|
501
|
+
stroke=1 if item.get("stroke") else 0, fill=1 if item.get("fill") else 0)
|
|
502
|
+
elif kind == "ellipse":
|
|
503
|
+
pdf.ellipse(item["x"] * mm, top(item["y"], item["h"]),
|
|
504
|
+
(item["x"] + item["w"]) * mm, top(item["y"]),
|
|
505
|
+
stroke=1 if item.get("stroke") else 0, fill=1 if item.get("fill") else 0)
|
|
506
|
+
elif kind in ("line", "arrow"):
|
|
507
|
+
x1, y1 = item["x"] * mm, top(item["y"])
|
|
508
|
+
x2, y2 = (item["x"] + item["w"]) * mm, top(item["y"] + item["h"])
|
|
509
|
+
pdf.line(x1, y1, x2, y2)
|
|
510
|
+
if kind == "arrow":
|
|
511
|
+
_arrow_head(pdf, x1, y1, x2, y2, item.get("line_width_pt", 0.75))
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _arrow_head(pdf, x1, y1, x2, y2, line_width):
|
|
515
|
+
import math
|
|
516
|
+
|
|
517
|
+
angle = math.atan2(y2 - y1, x2 - x1)
|
|
518
|
+
size = max(3.0, line_width * 4)
|
|
519
|
+
for direction in (-1, 1):
|
|
520
|
+
spread = angle + direction * math.radians(160)
|
|
521
|
+
pdf.line(x2, y2, x2 + size * math.cos(spread), y2 + size * math.sin(spread))
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _provenance_page(pdf, lines, document, mm):
|
|
525
|
+
"""Appended by default, because a figure that cannot say where it came from
|
|
526
|
+
is a figure a reviewer has to take on trust."""
|
|
527
|
+
from reportlab.lib.pagesizes import A4
|
|
528
|
+
|
|
529
|
+
pdf.setPageSize(A4)
|
|
530
|
+
width, height = A4
|
|
531
|
+
cursor = height - 20 * mm
|
|
532
|
+
pdf.setFillColor("#000000")
|
|
533
|
+
for line in lines:
|
|
534
|
+
if cursor < 20 * mm:
|
|
535
|
+
pdf.showPage()
|
|
536
|
+
pdf.setPageSize(A4)
|
|
537
|
+
pdf.setFillColor("#000000")
|
|
538
|
+
cursor = height - 20 * mm
|
|
539
|
+
bold = line and not line.startswith(" ") and line.isupper()
|
|
540
|
+
pdf.setFont("Helvetica-Bold" if bold else "Helvetica", 8.5)
|
|
541
|
+
pdf.drawString(18 * mm, cursor, line[:160])
|
|
542
|
+
cursor -= 11
|
|
543
|
+
pdf.showPage()
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# -- PNG and TIFF --------------------------------------------------------
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _write_rasters(document, pages, out_dir, stem, fmt, dpi):
|
|
550
|
+
"""One file per page, flattened.
|
|
551
|
+
|
|
552
|
+
Text is drawn with Pillow's own font because these formats have no vector
|
|
553
|
+
concept -- which is exactly why the PDF exists and why this is not described
|
|
554
|
+
as the editable master anywhere in the interface.
|
|
555
|
+
"""
|
|
556
|
+
from PIL import Image, ImageDraw
|
|
557
|
+
|
|
558
|
+
# Typefaces this build could not find, collected as it goes. Named in the
|
|
559
|
+
# report rather than silently substituted -- the standing rule for this
|
|
560
|
+
# exporter is that what it could not reproduce is stated.
|
|
561
|
+
notes = set()
|
|
562
|
+
written = []
|
|
563
|
+
for index, entry in enumerate(pages):
|
|
564
|
+
page = entry["page"]
|
|
565
|
+
scale = dpi / compose.MM_PER_INCH
|
|
566
|
+
width = max(1, int(round(page["size_mm"]["w"] * scale)))
|
|
567
|
+
height = max(1, int(round(page["size_mm"]["h"] * scale)))
|
|
568
|
+
# PNG is the one format here that can carry an alpha channel, so it is
|
|
569
|
+
# the one that honours a transparent page. TIFF gets white: a
|
|
570
|
+
# submission-format file that composited onto whatever the journal's
|
|
571
|
+
# tooling paints is a figure ruined at the last step, and silently.
|
|
572
|
+
transparent = page["background"] == schema.TRANSPARENT
|
|
573
|
+
if transparent and fmt == "png":
|
|
574
|
+
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
|
575
|
+
else:
|
|
576
|
+
canvas = Image.new(
|
|
577
|
+
"RGB", (width, height),
|
|
578
|
+
"#ffffff" if transparent else page["background"])
|
|
579
|
+
draw = ImageDraw.Draw(canvas)
|
|
580
|
+
|
|
581
|
+
for item in compose.page_instructions(document, page, entry["panels"], entry["annotations"]):
|
|
582
|
+
_draw_raster(canvas, draw, item, entry["images"], scale, dpi, notes)
|
|
583
|
+
|
|
584
|
+
suffix = "tif" if fmt == "tiff" else fmt
|
|
585
|
+
name = f"{stem}.{suffix}" if len(pages) == 1 else f"{stem}-{index + 1}.{suffix}"
|
|
586
|
+
path = out_dir / name
|
|
587
|
+
canvas.save(path, dpi=(dpi, dpi))
|
|
588
|
+
written.append(path)
|
|
589
|
+
return written, notes
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
#: Where to look for each family and weight, best match first.
|
|
593
|
+
#:
|
|
594
|
+
#: Nothing is bundled. Shipping the Liberation faces would make PNG and TIFF
|
|
595
|
+
#: typographically faithful and would also add a megabyte and a half, a
|
|
596
|
+
#: MANIFEST.in entry, a licence to carry and a font-loading path that can fail
|
|
597
|
+
#: for reasons unrelated to the figure -- so it is a change of its own, with its
|
|
598
|
+
#: own packaging test, rather than a rider on this one. Until then the raster
|
|
599
|
+
#: formats say what they substituted and the PDF stays the typographic master.
|
|
600
|
+
_RASTER_CANDIDATES = {
|
|
601
|
+
("Helvetica", False, False): ("Arial.ttf", "Helvetica.ttc",
|
|
602
|
+
"LiberationSans-Regular.ttf", "DejaVuSans.ttf"),
|
|
603
|
+
("Helvetica", True, False): ("Arial Bold.ttf", "Arial-Bold.ttf",
|
|
604
|
+
"LiberationSans-Bold.ttf", "DejaVuSans-Bold.ttf"),
|
|
605
|
+
("Helvetica", False, True): ("Arial Italic.ttf", "LiberationSans-Italic.ttf",
|
|
606
|
+
"DejaVuSans-Oblique.ttf"),
|
|
607
|
+
("Helvetica", True, True): ("Arial Bold Italic.ttf", "LiberationSans-BoldItalic.ttf",
|
|
608
|
+
"DejaVuSans-BoldOblique.ttf"),
|
|
609
|
+
("Times-Roman", False, False): ("Times New Roman.ttf", "Times.ttc",
|
|
610
|
+
"LiberationSerif-Regular.ttf", "DejaVuSerif.ttf"),
|
|
611
|
+
("Times-Roman", True, False): ("Times New Roman Bold.ttf",
|
|
612
|
+
"LiberationSerif-Bold.ttf", "DejaVuSerif-Bold.ttf"),
|
|
613
|
+
("Times-Roman", False, True): ("Times New Roman Italic.ttf",
|
|
614
|
+
"LiberationSerif-Italic.ttf", "DejaVuSerif-Italic.ttf"),
|
|
615
|
+
("Times-Roman", True, True): ("Times New Roman Bold Italic.ttf",
|
|
616
|
+
"LiberationSerif-BoldItalic.ttf",
|
|
617
|
+
"DejaVuSerif-BoldItalic.ttf"),
|
|
618
|
+
("Courier", False, False): ("Courier New.ttf", "Courier.ttc",
|
|
619
|
+
"LiberationMono-Regular.ttf", "DejaVuSansMono.ttf"),
|
|
620
|
+
("Courier", True, False): ("Courier New Bold.ttf", "LiberationMono-Bold.ttf",
|
|
621
|
+
"DejaVuSansMono-Bold.ttf"),
|
|
622
|
+
("Courier", False, True): ("Courier New Italic.ttf", "LiberationMono-Italic.ttf",
|
|
623
|
+
"DejaVuSansMono-Oblique.ttf"),
|
|
624
|
+
("Courier", True, True): ("Courier New Bold Italic.ttf",
|
|
625
|
+
"LiberationMono-BoldItalic.ttf",
|
|
626
|
+
"DejaVuSansMono-BoldOblique.ttf"),
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
#: Resolved fonts, keyed by family, weight and pixel size. A caption is one
|
|
630
|
+
#: `truetype` call per run otherwise, and a page of legends is hundreds.
|
|
631
|
+
_RASTER_FONTS = {}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _raster_font(run, dpi, notes):
|
|
635
|
+
"""A Pillow font for one run, and a note when it is not the one asked for.
|
|
636
|
+
|
|
637
|
+
`ImageFont.load_default(size=...)` returns a real FreeTypeFont on Pillow
|
|
638
|
+
10.1 and up -- and `pyproject.toml` pins 10.4 -- so the fallback still
|
|
639
|
+
measures and still places a baseline correctly. What it cannot do is bold,
|
|
640
|
+
italic, or any family at all: there is one face. So the LAYOUT is right on
|
|
641
|
+
this path and only the typeface is wrong, which is worth stating precisely
|
|
642
|
+
rather than as "raster text is approximate".
|
|
643
|
+
"""
|
|
644
|
+
from PIL import ImageFont
|
|
645
|
+
|
|
646
|
+
size = max(6, int(round(run["size_pt"] / 72.0 * dpi)))
|
|
647
|
+
key = (run["family"], run["bold"], run["italic"], size)
|
|
648
|
+
if key not in _RASTER_FONTS:
|
|
649
|
+
resolved = None
|
|
650
|
+
for name in _RASTER_CANDIDATES.get(key[:3], ()):
|
|
651
|
+
try:
|
|
652
|
+
resolved = ImageFont.truetype(name, size)
|
|
653
|
+
break
|
|
654
|
+
except OSError:
|
|
655
|
+
continue
|
|
656
|
+
_RASTER_FONTS[key] = (resolved or ImageFont.load_default(size=size),
|
|
657
|
+
None if resolved else textmetrics.postscript_name(
|
|
658
|
+
run["family"], run["bold"], run["italic"]))
|
|
659
|
+
font, missing = _RASTER_FONTS[key]
|
|
660
|
+
if missing:
|
|
661
|
+
notes.add(missing)
|
|
662
|
+
return font
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _raster_decorations(draw, run, x, baseline, width, scale):
|
|
666
|
+
"""Underline and strike, drawn from the same em fractions as everywhere else.
|
|
667
|
+
|
|
668
|
+
Note the signs are the opposite of the PDF's: a raster canvas counts y
|
|
669
|
+
downwards, so an underline sits at a POSITIVE offset from the baseline here
|
|
670
|
+
and a negative one there.
|
|
671
|
+
"""
|
|
672
|
+
em = run["size_pt"] * textmetrics.MM_PER_PT * scale
|
|
673
|
+
for on, offset in ((run["underline"], textmetrics.UNDERLINE_OFFSET_EM),
|
|
674
|
+
(run["strike"], -textmetrics.STRIKE_OFFSET_EM)):
|
|
675
|
+
if not on:
|
|
676
|
+
continue
|
|
677
|
+
thickness = max(1.0, em * textmetrics.UNDERLINE_THICKNESS_EM)
|
|
678
|
+
y = baseline + em * offset
|
|
679
|
+
draw.rectangle([x, y, x + width, y + thickness], fill=run["color"] or "#000000")
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _draw_raster(canvas, draw, item, images, scale, dpi, notes):
|
|
683
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
684
|
+
|
|
685
|
+
# Pillow cannot rotate a drawing operation, so a rotated instruction is
|
|
686
|
+
# drawn square onto its own transparent layer and the layer is turned about
|
|
687
|
+
# the same pivot the other two renderers use. `rotate` counts anticlockwise
|
|
688
|
+
# and this canvas counts y downwards, so the angle is negated to turn the
|
|
689
|
+
# same way the screen does.
|
|
690
|
+
rotation = item.get("rotation")
|
|
691
|
+
if rotation:
|
|
692
|
+
pivot = item["pivot"]
|
|
693
|
+
layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
|
694
|
+
_draw_raster(layer, ImageDraw.Draw(layer),
|
|
695
|
+
{k: v for k, v in item.items() if k != "rotation"},
|
|
696
|
+
images, scale, dpi, notes)
|
|
697
|
+
layer = layer.rotate(-rotation, resample=Image.BICUBIC,
|
|
698
|
+
center=(pivot["x"] * scale, pivot["y"] * scale))
|
|
699
|
+
canvas.paste(layer, (0, 0), layer)
|
|
700
|
+
return
|
|
701
|
+
|
|
702
|
+
def box(entry):
|
|
703
|
+
return [entry["x"] * scale, entry["y"] * scale,
|
|
704
|
+
(entry["x"] + entry.get("w", 0)) * scale,
|
|
705
|
+
(entry["y"] + entry.get("h", 0)) * scale]
|
|
706
|
+
|
|
707
|
+
kind = item["kind"]
|
|
708
|
+
if kind == "panel":
|
|
709
|
+
image = images.get(item["panel_id"])
|
|
710
|
+
if image is not None:
|
|
711
|
+
canvas.paste(image, (int(item["x"] * scale), int(item["y"] * scale)))
|
|
712
|
+
return
|
|
713
|
+
if kind == "text":
|
|
714
|
+
# The instruction carries the baseline, so this anchors on it ("ls")
|
|
715
|
+
# rather than on the top of the line -- which is what the "la"/"ma"/"ra"
|
|
716
|
+
# anchors used to do, using Pillow's idea of the ascent where the PDF
|
|
717
|
+
# used its own. That was the same decision made twice with two different
|
|
718
|
+
# constants, and it is now made once, in `compose`.
|
|
719
|
+
fonts = [_raster_font(run, dpi, notes) for run in item["runs"]]
|
|
720
|
+
widths = [draw.textlength(run["text"], font=font)
|
|
721
|
+
for run, font in zip(item["runs"], fonts)]
|
|
722
|
+
line_width = sum(widths)
|
|
723
|
+
if item["runs"] and item["runs"][-1]["text"] != item["runs"][-1]["text"].rstrip():
|
|
724
|
+
line_width -= widths[-1] - draw.textlength(
|
|
725
|
+
item["runs"][-1]["text"].rstrip(), font=fonts[-1])
|
|
726
|
+
|
|
727
|
+
box_width = item.get("w", 0) * scale
|
|
728
|
+
align = item.get("align", "left")
|
|
729
|
+
if align == "center":
|
|
730
|
+
pen = item["x"] * scale + (box_width - line_width) / 2
|
|
731
|
+
elif align == "right":
|
|
732
|
+
pen = item["x"] * scale + box_width - line_width
|
|
733
|
+
else:
|
|
734
|
+
pen = item["x"] * scale
|
|
735
|
+
gaps = sum(run["text"].count(" ") for run in item["runs"]) if align == "justify" else 0
|
|
736
|
+
extra = (box_width - line_width) / gaps if gaps and box_width > line_width else 0.0
|
|
737
|
+
|
|
738
|
+
baseline = item["y"] * scale
|
|
739
|
+
for run, font, width in zip(item["runs"], fonts, widths):
|
|
740
|
+
spread = extra * run["text"].count(" ")
|
|
741
|
+
if extra:
|
|
742
|
+
# Pillow has no word-spacing, so a justified line is drawn word
|
|
743
|
+
# by word at computed pen positions.
|
|
744
|
+
for index, word in enumerate(run["text"].split(" ")):
|
|
745
|
+
if index:
|
|
746
|
+
pen += draw.textlength(" ", font=font) + extra
|
|
747
|
+
draw.text((pen, baseline), word, fill=run["color"] or "#000000",
|
|
748
|
+
font=font, anchor="ls")
|
|
749
|
+
pen += draw.textlength(word, font=font)
|
|
750
|
+
else:
|
|
751
|
+
draw.text((pen, baseline), run["text"], fill=run["color"] or "#000000",
|
|
752
|
+
font=font, anchor="ls")
|
|
753
|
+
pen += width
|
|
754
|
+
_raster_decorations(draw, run, pen - width - spread, baseline,
|
|
755
|
+
width + spread, scale)
|
|
756
|
+
return
|
|
757
|
+
if kind == "swatch":
|
|
758
|
+
ramp = item.get("ramp")
|
|
759
|
+
if ramp:
|
|
760
|
+
step = item["w"] * scale / max(1, len(ramp))
|
|
761
|
+
for index, colour in enumerate(ramp):
|
|
762
|
+
left = item["x"] * scale + index * step
|
|
763
|
+
draw.rectangle([left, item["y"] * scale, left + step,
|
|
764
|
+
(item["y"] + item["h"]) * scale], fill=colour)
|
|
765
|
+
else:
|
|
766
|
+
draw.rectangle(box(item), fill=item["color"])
|
|
767
|
+
return
|
|
768
|
+
width = max(1, int(round(item.get("line_width_pt", 0.75) / 72.0 * dpi)))
|
|
769
|
+
if kind == "rect":
|
|
770
|
+
draw.rectangle(box(item), fill=item.get("fill"), outline=item.get("stroke"), width=width)
|
|
771
|
+
elif kind == "ellipse":
|
|
772
|
+
draw.ellipse(box(item), fill=item.get("fill"), outline=item.get("stroke"), width=width)
|
|
773
|
+
elif kind in ("line", "arrow"):
|
|
774
|
+
draw.line(box(item), fill=item.get("stroke"), width=width)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _safe_stem(title):
|
|
778
|
+
"""A filename from a figure's title, or "" if nothing survives.
|
|
779
|
+
|
|
780
|
+
Whitelisted rather than escaped: this becomes a path, and a title is
|
|
781
|
+
whatever the user typed.
|
|
782
|
+
"""
|
|
783
|
+
kept = [c if (c.isalnum() or c in " -_") else " " for c in (title or "")]
|
|
784
|
+
return "_".join("".join(kept).split())[:60]
|