scitex 2.4.3__py3-none-any.whl → 2.5.0__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 (45) hide show
  1. scitex/__version__.py +1 -1
  2. scitex/io/_load.py +5 -0
  3. scitex/io/_load_modules/_canvas.py +171 -0
  4. scitex/io/_save.py +8 -0
  5. scitex/io/_save_modules/_canvas.py +356 -0
  6. scitex/plt/_subplots/_export_as_csv_formatters/_format_plot.py +77 -22
  7. scitex/plt/docs/FIGURE_ARCHITECTURE.md +257 -0
  8. scitex/plt/utils/__init__.py +10 -0
  9. scitex/plt/utils/_collect_figure_metadata.py +14 -12
  10. scitex/plt/utils/_csv_column_naming.py +237 -0
  11. scitex/session/_decorator.py +13 -1
  12. scitex/vis/README.md +246 -615
  13. scitex/vis/__init__.py +138 -78
  14. scitex/vis/canvas.py +423 -0
  15. scitex/vis/docs/CANVAS_ARCHITECTURE.md +307 -0
  16. scitex/vis/editor/__init__.py +1 -1
  17. scitex/vis/editor/_dearpygui_editor.py +1830 -0
  18. scitex/vis/editor/_defaults.py +40 -1
  19. scitex/vis/editor/_edit.py +54 -18
  20. scitex/vis/editor/_flask_editor.py +37 -0
  21. scitex/vis/editor/_qt_editor.py +865 -0
  22. scitex/vis/editor/flask_editor/__init__.py +21 -0
  23. scitex/vis/editor/flask_editor/bbox.py +216 -0
  24. scitex/vis/editor/flask_editor/core.py +152 -0
  25. scitex/vis/editor/flask_editor/plotter.py +130 -0
  26. scitex/vis/editor/flask_editor/renderer.py +184 -0
  27. scitex/vis/editor/flask_editor/templates/__init__.py +33 -0
  28. scitex/vis/editor/flask_editor/templates/html.py +295 -0
  29. scitex/vis/editor/flask_editor/templates/scripts.py +614 -0
  30. scitex/vis/editor/flask_editor/templates/styles.py +549 -0
  31. scitex/vis/editor/flask_editor/utils.py +81 -0
  32. scitex/vis/io/__init__.py +84 -21
  33. scitex/vis/io/canvas.py +226 -0
  34. scitex/vis/io/data.py +204 -0
  35. scitex/vis/io/directory.py +202 -0
  36. scitex/vis/io/export.py +460 -0
  37. scitex/vis/io/panel.py +424 -0
  38. {scitex-2.4.3.dist-info → scitex-2.5.0.dist-info}/METADATA +9 -2
  39. {scitex-2.4.3.dist-info → scitex-2.5.0.dist-info}/RECORD +42 -21
  40. scitex/vis/DJANGO_INTEGRATION.md +0 -677
  41. scitex/vis/editor/_web_editor.py +0 -1440
  42. scitex/vis/tmp.txt +0 -239
  43. {scitex-2.4.3.dist-info → scitex-2.5.0.dist-info}/WHEEL +0 -0
  44. {scitex-2.4.3.dist-info → scitex-2.5.0.dist-info}/entry_points.txt +0 -0
  45. {scitex-2.4.3.dist-info → scitex-2.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # Timestamp: 2025-12-08
4
+ # File: ./src/scitex/vis/io/directory.py
5
+ """
6
+ Directory operations for canvas storage.
7
+
8
+ Canvas directories use .canvas extension for portability and distinguishability:
9
+ {parent_dir}/{canvas_name}.canvas/
10
+ ├── canvas.json
11
+ ├── panels/
12
+ └── exports/
13
+
14
+ The .canvas extension makes canvas directories:
15
+ - Self-documenting (clearly a canvas bundle)
16
+ - Portable (can be moved/copied as a unit)
17
+ - Detectable by scitex.io
18
+ """
19
+
20
+ from pathlib import Path
21
+ from typing import List, Union
22
+ import shutil
23
+
24
+
25
+ SCHEMA_VERSION = "2.0.0"
26
+ CANVAS_EXTENSION = ".canvas"
27
+
28
+
29
+ def _normalize_canvas_name(canvas_name: str) -> str:
30
+ """Ensure canvas_name has .canvas extension."""
31
+ if not canvas_name.endswith(CANVAS_EXTENSION):
32
+ return canvas_name + CANVAS_EXTENSION
33
+ return canvas_name
34
+
35
+
36
+ def _strip_canvas_extension(canvas_name: str) -> str:
37
+ """Remove .canvas extension from canvas_name."""
38
+ if canvas_name.endswith(CANVAS_EXTENSION):
39
+ return canvas_name[:-len(CANVAS_EXTENSION)]
40
+ return canvas_name
41
+
42
+
43
+ def ensure_canvas_directory(
44
+ parent_dir: Union[str, Path],
45
+ canvas_name: str,
46
+ ) -> Path:
47
+ """
48
+ Create canvas directory structure if not exists.
49
+
50
+ Creates:
51
+ - {parent_dir}/{canvas_name}.canvas/
52
+ - {parent_dir}/{canvas_name}.canvas/panels/
53
+ - {parent_dir}/{canvas_name}.canvas/exports/
54
+ - {parent_dir}/{canvas_name}.canvas/canvas.json (empty template if not exists)
55
+
56
+ Parameters
57
+ ----------
58
+ parent_dir : str or Path
59
+ Parent directory where canvas will be created
60
+ canvas_name : str
61
+ Descriptive canvas name (e.g., "fig1_neural_results")
62
+ .canvas extension is added automatically if not present
63
+
64
+ Returns
65
+ -------
66
+ Path
67
+ Path to canvas directory (e.g., .../fig1_neural_results.canvas/)
68
+ """
69
+ canvas_dir = get_canvas_directory_path(parent_dir, canvas_name)
70
+
71
+ # Create directory structure (exports go directly in canvas dir)
72
+ canvas_dir.mkdir(parents=True, exist_ok=True)
73
+ (canvas_dir / "panels").mkdir(exist_ok=True)
74
+
75
+ # Create empty canvas.json if not exists
76
+ json_path = canvas_dir / "canvas.json"
77
+ if not json_path.exists():
78
+ from .canvas import _get_empty_canvas_template
79
+ import json
80
+ # Use the name without extension for canvas_name in JSON
81
+ base_name = _strip_canvas_extension(canvas_name)
82
+ template = _get_empty_canvas_template(base_name)
83
+ with open(json_path, "w") as f:
84
+ json.dump(template, f, indent=2)
85
+
86
+ return canvas_dir
87
+
88
+
89
+ def get_canvas_directory_path(
90
+ parent_dir: Union[str, Path],
91
+ canvas_name: str,
92
+ ) -> Path:
93
+ """
94
+ Get path to canvas directory.
95
+
96
+ Parameters
97
+ ----------
98
+ parent_dir : str or Path
99
+ Parent directory containing the canvas
100
+ canvas_name : str
101
+ Descriptive canvas name (with or without .canvas extension)
102
+
103
+ Returns
104
+ -------
105
+ Path
106
+ Path to {parent_dir}/{canvas_name}.canvas/
107
+ """
108
+ normalized_name = _normalize_canvas_name(canvas_name)
109
+ return Path(parent_dir) / normalized_name
110
+
111
+
112
+ def list_canvas_directories(
113
+ parent_dir: Union[str, Path],
114
+ include_extension: bool = False,
115
+ ) -> List[str]:
116
+ """
117
+ List all canvas directory names in parent directory.
118
+
119
+ Parameters
120
+ ----------
121
+ parent_dir : str or Path
122
+ Directory to search for canvas directories
123
+ include_extension : bool, optional
124
+ If True, return names with .canvas extension.
125
+ If False (default), return names without extension.
126
+
127
+ Returns
128
+ -------
129
+ List[str]
130
+ List of canvas_names
131
+ """
132
+ parent_dir = Path(parent_dir)
133
+
134
+ if not parent_dir.exists():
135
+ return []
136
+
137
+ # Find .canvas directories that contain canvas.json
138
+ canvas_names = []
139
+ for item in sorted(parent_dir.iterdir()):
140
+ if (item.is_dir()
141
+ and item.name.endswith(CANVAS_EXTENSION)
142
+ and (item / "canvas.json").exists()):
143
+ if include_extension:
144
+ canvas_names.append(item.name)
145
+ else:
146
+ canvas_names.append(_strip_canvas_extension(item.name))
147
+
148
+ return canvas_names
149
+
150
+
151
+ def delete_canvas_directory(
152
+ parent_dir: Union[str, Path],
153
+ canvas_name: str,
154
+ ) -> bool:
155
+ """
156
+ Delete canvas directory and all contents.
157
+
158
+ Parameters
159
+ ----------
160
+ parent_dir : str or Path
161
+ Parent directory containing the canvas
162
+ canvas_name : str
163
+ Descriptive canvas name
164
+
165
+ Returns
166
+ -------
167
+ bool
168
+ True if deleted successfully, False if directory didn't exist
169
+ """
170
+ canvas_dir = get_canvas_directory_path(parent_dir, canvas_name)
171
+
172
+ if not canvas_dir.exists():
173
+ return False
174
+
175
+ shutil.rmtree(canvas_dir)
176
+ return True
177
+
178
+
179
+ def canvas_directory_exists(
180
+ parent_dir: Union[str, Path],
181
+ canvas_name: str,
182
+ ) -> bool:
183
+ """
184
+ Check if canvas directory exists.
185
+
186
+ Parameters
187
+ ----------
188
+ parent_dir : str or Path
189
+ Parent directory containing the canvas
190
+ canvas_name : str
191
+ Descriptive canvas name
192
+
193
+ Returns
194
+ -------
195
+ bool
196
+ True if canvas directory exists with canvas.json
197
+ """
198
+ canvas_dir = get_canvas_directory_path(parent_dir, canvas_name)
199
+ return canvas_dir.exists() and (canvas_dir / "canvas.json").exists()
200
+
201
+
202
+ # EOF
@@ -0,0 +1,460 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # Timestamp: 2025-12-08
4
+ # File: ./src/scitex/vis/io/export.py
5
+ """
6
+ Export operations for scitex.vis.
7
+
8
+ Handles composing and exporting canvas to various formats (PNG, PDF, SVG).
9
+ """
10
+
11
+ from pathlib import Path
12
+ from typing import List, Union, Optional, Dict, Any
13
+
14
+
15
+ def export_canvas_to_file(
16
+ project_dir: Union[str, Path],
17
+ canvas_name: str,
18
+ output_format: str = "png",
19
+ dpi: int = 300,
20
+ transparent: bool = False,
21
+ ) -> Path:
22
+ """
23
+ Export canvas to specified format.
24
+
25
+ Composes all panels according to canvas.json and exports to a single image.
26
+
27
+ Parameters
28
+ ----------
29
+ project_dir : str or Path
30
+ Project root directory
31
+ canvas_name : str
32
+ Canvas name
33
+ output_format : str, optional
34
+ Output format: "png", "pdf", "svg", "eps" (default: "png")
35
+ dpi : int, optional
36
+ Resolution for raster formats (default: 300)
37
+ transparent : bool, optional
38
+ Use transparent background (default: False)
39
+
40
+ Returns
41
+ -------
42
+ Path
43
+ Path to exported file in exports/ directory
44
+ """
45
+ from .directory import get_canvas_directory_path
46
+ from .canvas import load_canvas_json
47
+
48
+ canvas_dir = get_canvas_directory_path(project_dir, canvas_name)
49
+ canvas_json = load_canvas_json(project_dir, canvas_name, verify_data_hashes=False)
50
+
51
+ exports_dir = canvas_dir / "exports"
52
+ exports_dir.mkdir(exist_ok=True)
53
+
54
+ output_path = exports_dir / f"canvas.{output_format}"
55
+
56
+ # Compose canvas
57
+ _compose_and_export(
58
+ canvas_dir=canvas_dir,
59
+ canvas_json=canvas_json,
60
+ output_path=output_path,
61
+ output_format=output_format,
62
+ dpi=dpi,
63
+ transparent=transparent,
64
+ )
65
+
66
+ return output_path
67
+
68
+
69
+ def export_canvas_to_multiple_formats(
70
+ project_dir: Union[str, Path],
71
+ canvas_name: str,
72
+ formats: List[str] = None,
73
+ dpi: int = 300,
74
+ transparent: bool = False,
75
+ ) -> List[Path]:
76
+ """
77
+ Export canvas to multiple formats.
78
+
79
+ Parameters
80
+ ----------
81
+ project_dir : str or Path
82
+ Project root directory
83
+ canvas_name : str
84
+ Canvas name
85
+ formats : List[str], optional
86
+ List of formats (default: ["png", "pdf", "svg"])
87
+ dpi : int, optional
88
+ Resolution for raster formats (default: 300)
89
+ transparent : bool, optional
90
+ Use transparent background (default: False)
91
+
92
+ Returns
93
+ -------
94
+ List[Path]
95
+ List of paths to exported files
96
+ """
97
+ if formats is None:
98
+ formats = ["png", "pdf", "svg"]
99
+
100
+ paths = []
101
+ for fmt in formats:
102
+ path = export_canvas_to_file(
103
+ project_dir=project_dir,
104
+ canvas_name=canvas_name,
105
+ output_format=fmt,
106
+ dpi=dpi,
107
+ transparent=transparent,
108
+ )
109
+ paths.append(path)
110
+
111
+ return paths
112
+
113
+
114
+ def list_canvas_exports(
115
+ project_dir: Union[str, Path],
116
+ canvas_name: str,
117
+ ) -> List[Path]:
118
+ """
119
+ List all exported files for a canvas.
120
+
121
+ Parameters
122
+ ----------
123
+ project_dir : str or Path
124
+ Project root directory
125
+ canvas_name : str
126
+ Canvas name
127
+
128
+ Returns
129
+ -------
130
+ List[Path]
131
+ List of paths in exports/ directory
132
+ """
133
+ from .directory import get_canvas_directory_path
134
+
135
+ canvas_dir = get_canvas_directory_path(project_dir, canvas_name)
136
+ exports_dir = canvas_dir / "exports"
137
+
138
+ if not exports_dir.exists():
139
+ return []
140
+
141
+ return sorted([p for p in exports_dir.iterdir() if p.is_file()])
142
+
143
+
144
+ def _compose_and_export(
145
+ canvas_dir: Path,
146
+ canvas_json: Dict[str, Any],
147
+ output_path: Path,
148
+ output_format: str,
149
+ dpi: int,
150
+ transparent: bool,
151
+ ) -> None:
152
+ """
153
+ Compose all panels and export to file.
154
+
155
+ Uses matplotlib for composition.
156
+ """
157
+ import matplotlib.pyplot as plt
158
+ from matplotlib.figure import Figure
159
+ from PIL import Image
160
+ import numpy as np
161
+
162
+ # Canvas size in mm
163
+ width_mm = canvas_json["size"]["width_mm"]
164
+ height_mm = canvas_json["size"]["height_mm"]
165
+
166
+ # Convert to inches for matplotlib
167
+ mm_to_inch = 1 / 25.4
168
+ width_inch = width_mm * mm_to_inch
169
+ height_inch = height_mm * mm_to_inch
170
+
171
+ # Create figure
172
+ fig = plt.figure(figsize=(width_inch, height_inch), dpi=dpi)
173
+
174
+ # Set background
175
+ bg_color = canvas_json.get("background", {}).get("color", "#ffffff")
176
+ if transparent:
177
+ fig.patch.set_alpha(0)
178
+ else:
179
+ fig.patch.set_facecolor(bg_color)
180
+
181
+ # Sort panels by z_index
182
+ panels = sorted(
183
+ canvas_json.get("panels", []),
184
+ key=lambda p: p.get("z_index", 0)
185
+ )
186
+
187
+ # Place each panel
188
+ for panel in panels:
189
+ if not panel.get("visible", True):
190
+ continue
191
+
192
+ _place_panel(
193
+ fig=fig,
194
+ canvas_dir=canvas_dir,
195
+ panel=panel,
196
+ canvas_width_mm=width_mm,
197
+ canvas_height_mm=height_mm,
198
+ )
199
+
200
+ # Add annotations
201
+ _add_annotations(
202
+ fig=fig,
203
+ annotations=canvas_json.get("annotations", []),
204
+ canvas_width_mm=width_mm,
205
+ canvas_height_mm=height_mm,
206
+ )
207
+
208
+ # Add title
209
+ title_config = canvas_json.get("title", {})
210
+ if title_config.get("text"):
211
+ _add_title(
212
+ fig=fig,
213
+ title_config=title_config,
214
+ canvas_width_mm=width_mm,
215
+ canvas_height_mm=height_mm,
216
+ )
217
+
218
+ # Add caption (figure legend in scientific sense) - only if render=True
219
+ caption_config = canvas_json.get("caption", {})
220
+ if caption_config.get("text") and caption_config.get("render", False):
221
+ _add_caption(
222
+ fig=fig,
223
+ caption_config=caption_config,
224
+ canvas_width_mm=width_mm,
225
+ canvas_height_mm=height_mm,
226
+ )
227
+
228
+ # Save
229
+ fig.savefig(
230
+ output_path,
231
+ format=output_format,
232
+ dpi=dpi,
233
+ bbox_inches="tight",
234
+ pad_inches=0,
235
+ transparent=transparent,
236
+ )
237
+
238
+ plt.close(fig)
239
+
240
+
241
+ def _place_panel(
242
+ fig: "Figure",
243
+ canvas_dir: Path,
244
+ panel: Dict[str, Any],
245
+ canvas_width_mm: float,
246
+ canvas_height_mm: float,
247
+ ) -> None:
248
+ """Place a single panel on the figure."""
249
+ from PIL import Image
250
+ import numpy as np
251
+
252
+ panel_name = panel.get("name", "")
253
+ panel_type = panel.get("type", "image")
254
+
255
+ # Get panel image path
256
+ panel_dir = canvas_dir / "panels" / panel_name
257
+
258
+ if panel_type == "scitex":
259
+ img_path = panel_dir / "panel.png"
260
+ else:
261
+ # Image type - use source filename
262
+ source = panel.get("source", "panel.png")
263
+ img_path = panel_dir / source
264
+
265
+ # Check if path exists (resolve symlinks)
266
+ if not img_path.exists():
267
+ # Try resolving symlink
268
+ try:
269
+ resolved_path = img_path.resolve()
270
+ if not resolved_path.exists():
271
+ return
272
+ img_path = resolved_path
273
+ except (OSError, ValueError):
274
+ return
275
+
276
+ # Load image
277
+ img = Image.open(img_path)
278
+
279
+ # Apply transforms
280
+ opacity = panel.get("opacity", 1.0)
281
+ flip_h = panel.get("flip_h", False)
282
+ flip_v = panel.get("flip_v", False)
283
+ rotation_deg = panel.get("rotation_deg", 0)
284
+
285
+ if flip_h:
286
+ img = img.transpose(Image.FLIP_LEFT_RIGHT)
287
+ if flip_v:
288
+ img = img.transpose(Image.FLIP_TOP_BOTTOM)
289
+ if rotation_deg != 0:
290
+ img = img.rotate(-rotation_deg, expand=True, resample=Image.BICUBIC)
291
+
292
+ # Apply clip
293
+ clip = panel.get("clip", {})
294
+ if clip.get("enabled", False):
295
+ # Clip is in mm relative to panel, need to convert to pixels
296
+ # For now, simple implementation using PIL crop
297
+ pass # TODO: Implement clipping
298
+
299
+ # Convert to numpy array
300
+ img_array = np.array(img)
301
+
302
+ # Handle opacity
303
+ if opacity < 1.0:
304
+ if img_array.ndim == 3 and img_array.shape[2] == 4:
305
+ # Has alpha channel
306
+ img_array[:, :, 3] = (img_array[:, :, 3] * opacity).astype(np.uint8)
307
+ elif img_array.ndim == 3 and img_array.shape[2] == 3:
308
+ # Add alpha channel
309
+ alpha = np.full(img_array.shape[:2], int(255 * opacity), dtype=np.uint8)
310
+ img_array = np.dstack([img_array, alpha])
311
+
312
+ # Position in normalized coordinates (0-1)
313
+ pos = panel.get("position", {})
314
+ size = panel.get("size", {})
315
+
316
+ x_mm = pos.get("x_mm", 0)
317
+ y_mm = pos.get("y_mm", 0)
318
+ w_mm = size.get("width_mm", 50)
319
+ h_mm = size.get("height_mm", 50)
320
+
321
+ # Convert to figure coordinates (origin bottom-left)
322
+ left = x_mm / canvas_width_mm
323
+ bottom = 1 - (y_mm + h_mm) / canvas_height_mm
324
+ width = w_mm / canvas_width_mm
325
+ height = h_mm / canvas_height_mm
326
+
327
+ # Create axes and place image
328
+ ax = fig.add_axes([left, bottom, width, height])
329
+ ax.imshow(img_array)
330
+ ax.axis("off")
331
+
332
+ # Add label
333
+ label = panel.get("label", {})
334
+ if label.get("text"):
335
+ _add_panel_label(ax, label)
336
+
337
+ # Add border
338
+ border = panel.get("border", {})
339
+ if border.get("visible", False):
340
+ for spine in ax.spines.values():
341
+ spine.set_visible(True)
342
+ spine.set_color(border.get("color", "#000000"))
343
+ spine.set_linewidth(border.get("width_mm", 0.2) * 72 / 25.4) # mm to points
344
+
345
+
346
+ def _add_panel_label(ax, label: Dict[str, Any]) -> None:
347
+ """Add label (A, B, C...) to panel."""
348
+ text = label.get("text", "")
349
+ position = label.get("position", "top-left")
350
+ fontsize = label.get("fontsize", 12)
351
+ fontweight = label.get("fontweight", "bold")
352
+
353
+ # Position mapping
354
+ pos_map = {
355
+ "top-left": (0.02, 0.98, "left", "top"),
356
+ "top-right": (0.98, 0.98, "right", "top"),
357
+ "bottom-left": (0.02, 0.02, "left", "bottom"),
358
+ "bottom-right": (0.98, 0.02, "right", "bottom"),
359
+ }
360
+
361
+ x, y, ha, va = pos_map.get(position, pos_map["top-left"])
362
+
363
+ ax.text(
364
+ x, y, text,
365
+ transform=ax.transAxes,
366
+ fontsize=fontsize,
367
+ fontweight=fontweight,
368
+ ha=ha, va=va,
369
+ )
370
+
371
+
372
+ def _add_annotations(
373
+ fig: "Figure",
374
+ annotations: List[Dict[str, Any]],
375
+ canvas_width_mm: float,
376
+ canvas_height_mm: float,
377
+ ) -> None:
378
+ """Add annotations to figure."""
379
+ for ann in annotations:
380
+ ann_type = ann.get("type", "")
381
+
382
+ if ann_type == "text":
383
+ pos = ann.get("position", {})
384
+ x = pos.get("x_mm", 0) / canvas_width_mm
385
+ y = 1 - pos.get("y_mm", 0) / canvas_height_mm
386
+
387
+ fig.text(
388
+ x, y,
389
+ ann.get("content", ""),
390
+ fontsize=ann.get("fontsize", 10),
391
+ color=ann.get("color", "#000000"),
392
+ )
393
+
394
+ # TODO: Implement arrow, bracket, line, rectangle annotations
395
+
396
+
397
+ def _add_title(
398
+ fig: "Figure",
399
+ title_config: Dict[str, Any],
400
+ canvas_width_mm: float,
401
+ canvas_height_mm: float,
402
+ ) -> None:
403
+ """Add title to figure."""
404
+ pos = title_config.get("position", {})
405
+ x = pos.get("x_mm", canvas_width_mm / 2) / canvas_width_mm
406
+ y = 1 - pos.get("y_mm", 5) / canvas_height_mm
407
+
408
+ fig.text(
409
+ x, y,
410
+ title_config.get("text", ""),
411
+ fontsize=title_config.get("fontsize", 14),
412
+ ha="center",
413
+ va="top",
414
+ )
415
+
416
+
417
+ def _add_caption(
418
+ fig: "Figure",
419
+ caption_config: Dict[str, Any],
420
+ canvas_width_mm: float,
421
+ canvas_height_mm: float,
422
+ ) -> None:
423
+ """
424
+ Add figure caption (legend in scientific sense).
425
+
426
+ Caption is placed below the figure by default.
427
+ Text is wrapped to fit within specified width.
428
+ """
429
+ import textwrap
430
+
431
+ text = caption_config.get("text", "")
432
+ fontsize = caption_config.get("fontsize", 10)
433
+ width_mm = caption_config.get("width_mm") or (canvas_width_mm - 20)
434
+
435
+ pos = caption_config.get("position", {})
436
+ x = pos.get("x_mm", 10) / canvas_width_mm
437
+ y = pos.get("y_mm", canvas_height_mm + 5) # Below canvas by default
438
+
439
+ # Convert to figure coordinates (y below canvas is negative in bbox_inches="tight")
440
+ y_norm = 1 - y / canvas_height_mm
441
+
442
+ # Estimate characters per line based on width and fontsize
443
+ # Approximate: 1 character ~ 0.6 * fontsize in points, 1 point ~ 0.35mm
444
+ chars_per_mm = 1 / (0.6 * fontsize * 0.35)
445
+ wrap_width = int(width_mm * chars_per_mm)
446
+
447
+ # Wrap text
448
+ wrapped_text = textwrap.fill(text, width=wrap_width)
449
+
450
+ fig.text(
451
+ x, y_norm,
452
+ wrapped_text,
453
+ fontsize=fontsize,
454
+ ha="left",
455
+ va="top",
456
+ wrap=True,
457
+ )
458
+
459
+
460
+ # EOF