plexora 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. plexora/__init__.py +129 -0
  2. plexora/__main__.py +15 -0
  3. plexora/_url.py +105 -0
  4. plexora/api/__init__.py +55 -0
  5. plexora/api/dataset.py +398 -0
  6. plexora/api/http.py +19 -0
  7. plexora/api/plugin.py +541 -0
  8. plexora/api/store.py +221 -0
  9. plexora/cli.py +777 -0
  10. plexora/client/dist/354_bundle.js +2 -0
  11. plexora/client/dist/354_bundle.js.LICENSE.txt +20 -0
  12. plexora/client/dist/418_bundle.js +2 -0
  13. plexora/client/dist/418_bundle.js.LICENSE.txt +1 -0
  14. plexora/client/dist/693b935cb1f907814be9c6199a68c217.svg +19 -0
  15. plexora/client/dist/713c134ff47cd328a5b711526f151082.svg +18 -0
  16. plexora/client/dist/770_bundle.js +1 -0
  17. plexora/client/dist/ff743f408972e0e96ca9153ed5ae4c83.svg +23 -0
  18. plexora/client/dist/vendor_bundle.js +2 -0
  19. plexora/client/dist/vendor_bundle.js.LICENSE.txt +308 -0
  20. plexora/client/external/openseadragon-bin-2.4.0/LICENSE.txt +28 -0
  21. plexora/client/external/openseadragon-bin-2.4.0/canvas-overlay-hd.js +140 -0
  22. plexora/client/external/openseadragon-bin-2.4.0/changelog.txt +501 -0
  23. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/.gitattributes +17 -0
  24. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/LICENSE.txt +116 -0
  25. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/Toolbar.png +0 -0
  26. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/fullpage.psd +0 -0
  27. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/home.psd +0 -0
  28. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/next.psd +0 -0
  29. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/previous.psd +0 -0
  30. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateleft.psd +0 -0
  31. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/rotateright.psd +0 -0
  32. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/Photoshop/zoom.psd +0 -0
  33. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/README.md +9 -0
  34. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_grouphover.png +0 -0
  35. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_hover.png +0 -0
  36. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_pressed.png +0 -0
  37. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/fullpage_rest.png +0 -0
  38. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_grouphover.png +0 -0
  39. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_hover.png +0 -0
  40. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_pressed.png +0 -0
  41. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/home_rest.png +0 -0
  42. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_grouphover.png +0 -0
  43. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_hover.png +0 -0
  44. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_pressed.png +0 -0
  45. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/next_rest.png +0 -0
  46. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_grouphover.png +0 -0
  47. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_hover.png +0 -0
  48. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_pressed.png +0 -0
  49. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/previous_rest.png +0 -0
  50. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_grouphover.png +0 -0
  51. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_hover.png +0 -0
  52. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_pressed.png +0 -0
  53. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateleft_rest.png +0 -0
  54. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_grouphover.png +0 -0
  55. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_hover.png +0 -0
  56. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_pressed.png +0 -0
  57. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/rotateright_rest.png +0 -0
  58. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_grouphover.png +0 -0
  59. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_hover.png +0 -0
  60. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_pressed.png +0 -0
  61. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomin_rest.png +0 -0
  62. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_grouphover.png +0 -0
  63. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_hover.png +0 -0
  64. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_pressed.png +0 -0
  65. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-flat-toolbar-icons-master/images/zoomout_rest.png +0 -0
  66. plexora/client/external/openseadragon-bin-2.4.0/openseadragon-scalebar.js +562 -0
  67. plexora/client/src/css/import.css +474 -0
  68. plexora/client/src/css/main.css +903 -0
  69. plexora/client/src/css/openProject.css +363 -0
  70. plexora/client/src/css/quickView.css +166 -0
  71. plexora/client/src/css/tokens.css +51 -0
  72. plexora/client/src/css/viewer.css +1655 -0
  73. plexora/client/src/img/apple-touch-icon.png +0 -0
  74. plexora/client/src/img/favicon.ico +0 -0
  75. plexora/client/src/img/logo.ai +8892 -33
  76. plexora/client/src/img/logo.svg +1 -0
  77. plexora/client/src/img/logo_with_text.ai +9011 -34
  78. plexora/client/src/img/logo_with_text.svg +1 -0
  79. plexora/client/src/js/main.js +704 -0
  80. plexora/client/src/js/pluginRegistry.js +108 -0
  81. plexora/client/src/js/services/appStatus.js +286 -0
  82. plexora/client/src/js/services/browsePicker.js +73 -0
  83. plexora/client/src/js/services/dataLayer.js +400 -0
  84. plexora/client/src/js/services/datasetContext.js +143 -0
  85. plexora/client/src/js/services/glRenderer.js +143 -0
  86. plexora/client/src/js/services/importFormValidation.js +257 -0
  87. plexora/client/src/js/services/numericData.js +100 -0
  88. plexora/client/src/js/services/passVariablesToFrontend.js +20 -0
  89. plexora/client/src/js/services/simpleEventHandler.js +25 -0
  90. plexora/client/src/js/vendor.js +29 -0
  91. plexora/client/src/js/views/channelList.js +663 -0
  92. plexora/client/src/js/views/colorSwatchPicker.js +202 -0
  93. plexora/client/src/js/views/columnClassifier.js +146 -0
  94. plexora/client/src/js/views/coordinateField.js +133 -0
  95. plexora/client/src/js/views/dataSourceField.js +276 -0
  96. plexora/client/src/js/views/imageViewer.js +3306 -0
  97. plexora/client/src/js/views/miniMap.js +787 -0
  98. plexora/client/src/js/views/navbarControls.js +178 -0
  99. plexora/client/src/js/views/openProjectPage.js +239 -0
  100. plexora/client/src/js/views/projectEdit.js +335 -0
  101. plexora/client/src/js/views/quickViewLanding.js +136 -0
  102. plexora/client/src/js/views/rainbow.js +326 -0
  103. plexora/client/src/js/views/requirementsModal.js +572 -0
  104. plexora/client/src/js/views/rgbImageViewer.js +115 -0
  105. plexora/client/src/js/views/searchableSelect.js +411 -0
  106. plexora/client/src/js/views/segmentationProgress.js +122 -0
  107. plexora/client/src/js/views/toolLoader.js +747 -0
  108. plexora/client/src/js/views/viewerControls.js +689 -0
  109. plexora/client/src/js/views/viewerManager.js +434 -0
  110. plexora/client/src/js/views/viewerSidebar.js +1117 -0
  111. plexora/client/src/js/workers/tileDecoder.js +130 -0
  112. plexora/client/src/shaders/frag.glsl +528 -0
  113. plexora/client/src/shaders/vert.glsl +12 -0
  114. plexora/client/templates/base.html +212 -0
  115. plexora/client/templates/index.html +238 -0
  116. plexora/client/templates/open_project.html +96 -0
  117. plexora/client/templates/project_columns.html +86 -0
  118. plexora/client/templates/project_edit.html +196 -0
  119. plexora/client/templates/upload.html +118 -0
  120. plexora/connect.py +528 -0
  121. plexora/datasource.py +782 -0
  122. plexora/jupyter.py +406 -0
  123. plexora/notebook_env.py +232 -0
  124. plexora/paths.py +486 -0
  125. plexora/plugins/__init__.py +12 -0
  126. plexora/plugins/cell_explorer/__init__.py +100 -0
  127. plexora/plugins/cell_explorer/server/__init__.py +11 -0
  128. plexora/plugins/cell_explorer/server/routes.py +167 -0
  129. plexora/plugins/cell_explorer/server/state.py +281 -0
  130. plexora/plugins/cell_explorer/server/values.py +145 -0
  131. plexora/plugins/cell_explorer/server/variables.py +367 -0
  132. plexora/plugins/cell_explorer/static/cellExplorerApi.js +158 -0
  133. plexora/plugins/cell_explorer/static/cellExplorerColors.js +316 -0
  134. plexora/plugins/cell_explorer/static/cellExplorerContinuous.js +403 -0
  135. plexora/plugins/cell_explorer/static/cellExplorerFigureBridge.js +133 -0
  136. plexora/plugins/cell_explorer/static/cellExplorerLegend.js +188 -0
  137. plexora/plugins/cell_explorer/static/cellExplorerRoiBridge.js +822 -0
  138. plexora/plugins/cell_explorer/static/cellExplorerSidebarController.js +826 -0
  139. plexora/plugins/cell_explorer/static/cellExplorerState.js +372 -0
  140. plexora/plugins/cell_explorer/static/cell_explorer.css +752 -0
  141. plexora/plugins/cell_explorer/templates/cell_explorer/panel.html +137 -0
  142. plexora/plugins/figure_builder/__init__.py +125 -0
  143. plexora/plugins/figure_builder/server/__init__.py +7 -0
  144. plexora/plugins/figure_builder/server/compose.py +365 -0
  145. plexora/plugins/figure_builder/server/export.py +784 -0
  146. plexora/plugins/figure_builder/server/export_jobs.py +148 -0
  147. plexora/plugins/figure_builder/server/operations.py +556 -0
  148. plexora/plugins/figure_builder/server/pixels.py +142 -0
  149. plexora/plugins/figure_builder/server/provenance.py +174 -0
  150. plexora/plugins/figure_builder/server/render.py +278 -0
  151. plexora/plugins/figure_builder/server/repository.py +701 -0
  152. plexora/plugins/figure_builder/server/routes.py +594 -0
  153. plexora/plugins/figure_builder/server/schema.py +1023 -0
  154. plexora/plugins/figure_builder/server/sources.py +120 -0
  155. plexora/plugins/figure_builder/server/textmetrics.py +155 -0
  156. plexora/plugins/figure_builder/static/figureActions.js +363 -0
  157. plexora/plugins/figure_builder/static/figureBuilderApi.js +255 -0
  158. plexora/plugins/figure_builder/static/figureCanvas.js +2362 -0
  159. plexora/plugins/figure_builder/static/figureCaptureBoxes.js +369 -0
  160. plexora/plugins/figure_builder/static/figureCaptureDock.js +466 -0
  161. plexora/plugins/figure_builder/static/figureCaptureTool.js +1034 -0
  162. plexora/plugins/figure_builder/static/figureConfirm.js +136 -0
  163. plexora/plugins/figure_builder/static/figureContextBar.js +1193 -0
  164. plexora/plugins/figure_builder/static/figureContextMenu.js +253 -0
  165. plexora/plugins/figure_builder/static/figureDocumentState.js +275 -0
  166. plexora/plugins/figure_builder/static/figureExportUi.js +174 -0
  167. plexora/plugins/figure_builder/static/figureLibrary.js +247 -0
  168. plexora/plugins/figure_builder/static/figureQuickEdit.js +628 -0
  169. plexora/plugins/figure_builder/static/figureRichText.js +634 -0
  170. plexora/plugins/figure_builder/static/figureSceneSnapshot.js +361 -0
  171. plexora/plugins/figure_builder/static/figureSchema.js +218 -0
  172. plexora/plugins/figure_builder/static/figureSidebarController.js +1159 -0
  173. plexora/plugins/figure_builder/static/figureTextEditor.js +503 -0
  174. plexora/plugins/figure_builder/static/figureTextPanel.js +491 -0
  175. plexora/plugins/figure_builder/static/figureViewOptions.js +273 -0
  176. plexora/plugins/figure_builder/static/figureWorkspace.js +2030 -0
  177. plexora/plugins/figure_builder/static/figure_builder.css +3292 -0
  178. plexora/plugins/figure_builder/templates/figure_builder/library.html +74 -0
  179. plexora/plugins/figure_builder/templates/figure_builder/workspace.html +27 -0
  180. plexora/plugins/figure_builder/templates/figure_builder/workspace_body.html +458 -0
  181. plexora/plugins/gating/__init__.py +76 -0
  182. plexora/plugins/gating/server/__init__.py +0 -0
  183. plexora/plugins/gating/server/anndata_gates.py +367 -0
  184. plexora/plugins/gating/server/database.py +16 -0
  185. plexora/plugins/gating/server/model.py +320 -0
  186. plexora/plugins/gating/server/routes.py +235 -0
  187. plexora/plugins/gating/static/csvGatingList.js +947 -0
  188. plexora/plugins/gating/static/gating.css +289 -0
  189. plexora/plugins/gating/static/gatingApi.js +221 -0
  190. plexora/plugins/gating/static/gatingSidebarController.js +513 -0
  191. plexora/plugins/gating/templates/gating/legacy.html +26 -0
  192. plexora/plugins/gating/templates/gating/panel.html +69 -0
  193. plexora/plugins/roi/__init__.py +95 -0
  194. plexora/plugins/roi/server/__init__.py +8 -0
  195. plexora/plugins/roi/server/adapters.py +655 -0
  196. plexora/plugins/roi/server/geojson.py +267 -0
  197. plexora/plugins/roi/server/geometry.py +181 -0
  198. plexora/plugins/roi/server/mapping.py +169 -0
  199. plexora/plugins/roi/server/operations.py +293 -0
  200. plexora/plugins/roi/server/repository.py +215 -0
  201. plexora/plugins/roi/server/routes.py +409 -0
  202. plexora/plugins/roi/server/schema.py +323 -0
  203. plexora/plugins/roi/static/roi.css +583 -0
  204. plexora/plugins/roi/static/roiApi.js +177 -0
  205. plexora/plugins/roi/static/roiFigureBridge.js +107 -0
  206. plexora/plugins/roi/static/roiGeometry.js +350 -0
  207. plexora/plugins/roi/static/roiRenderer.js +278 -0
  208. plexora/plugins/roi/static/roiSidebarController.js +1038 -0
  209. plexora/plugins/roi/static/roiState.js +518 -0
  210. plexora/plugins/roi/static/roiTools.js +993 -0
  211. plexora/plugins/roi/templates/roi/panel.html +240 -0
  212. plexora/proxy.py +57 -0
  213. plexora/server/models/adapters/__init__.py +94 -0
  214. plexora/server/models/adapters/anndata_adapter.py +388 -0
  215. plexora/server/models/adapters/base.py +93 -0
  216. plexora/server/models/adapters/classify.py +192 -0
  217. plexora/server/models/adapters/csv_adapter.py +105 -0
  218. plexora/server/models/adapters/inspection.py +265 -0
  219. plexora/server/models/adapters/spatialdata_adapter.py +248 -0
  220. plexora/server/models/centroid_tiles.py +305 -0
  221. plexora/server/models/data_model.py +1659 -0
  222. plexora/server/models/database_model.py +169 -0
  223. plexora/server/models/project.py +1270 -0
  224. plexora/server/plugins.py +199 -0
  225. plexora/server/routes/browse_routes.py +41 -0
  226. plexora/server/routes/data_routes.py +294 -0
  227. plexora/server/routes/import_routes.py +603 -0
  228. plexora/server/routes/page_routes.py +139 -0
  229. plexora/server/routes/project_routes.py +438 -0
  230. plexora/server/routes/quick_view_routes.py +62 -0
  231. plexora/server/routes/system_routes.py +39 -0
  232. plexora/server/routes/tool_routes.py +418 -0
  233. plexora/server/utils/addHEColumns.py +11 -0
  234. plexora/server/utils/fast_png.py +97 -0
  235. plexora/server/utils/fullConversion.py +50 -0
  236. plexora/server/utils/native_dialog.py +168 -0
  237. plexora/server/utils/pre_normalization.py +47 -0
  238. plexora/server/utils/segmentation_pyramid.py +598 -0
  239. plexora/server/utils/smallestenclosingcircle.py +126 -0
  240. plexora/server/utils/tiffsurgeon.py +372 -0
  241. plexora/server_cli.py +48 -0
  242. plexora/test.py +0 -0
  243. plexora-0.0.1.dist-info/METADATA +341 -0
  244. plexora-0.0.1.dist-info/RECORD +248 -0
  245. plexora-0.0.1.dist-info/WHEEL +5 -0
  246. plexora-0.0.1.dist-info/entry_points.txt +12 -0
  247. plexora-0.0.1.dist-info/licenses/LICENSE +207 -0
  248. plexora-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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]