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.
- scitex/__version__.py +1 -1
- scitex/io/_load.py +5 -0
- scitex/io/_load_modules/_canvas.py +171 -0
- scitex/io/_save.py +8 -0
- scitex/io/_save_modules/_canvas.py +356 -0
- scitex/plt/_subplots/_export_as_csv_formatters/_format_plot.py +77 -22
- scitex/plt/docs/FIGURE_ARCHITECTURE.md +257 -0
- scitex/plt/utils/__init__.py +10 -0
- scitex/plt/utils/_collect_figure_metadata.py +14 -12
- scitex/plt/utils/_csv_column_naming.py +237 -0
- scitex/session/_decorator.py +13 -1
- scitex/vis/README.md +246 -615
- scitex/vis/__init__.py +138 -78
- scitex/vis/canvas.py +423 -0
- scitex/vis/docs/CANVAS_ARCHITECTURE.md +307 -0
- scitex/vis/editor/__init__.py +1 -1
- scitex/vis/editor/_dearpygui_editor.py +1830 -0
- scitex/vis/editor/_defaults.py +40 -1
- scitex/vis/editor/_edit.py +54 -18
- scitex/vis/editor/_flask_editor.py +37 -0
- scitex/vis/editor/_qt_editor.py +865 -0
- scitex/vis/editor/flask_editor/__init__.py +21 -0
- scitex/vis/editor/flask_editor/bbox.py +216 -0
- scitex/vis/editor/flask_editor/core.py +152 -0
- scitex/vis/editor/flask_editor/plotter.py +130 -0
- scitex/vis/editor/flask_editor/renderer.py +184 -0
- scitex/vis/editor/flask_editor/templates/__init__.py +33 -0
- scitex/vis/editor/flask_editor/templates/html.py +295 -0
- scitex/vis/editor/flask_editor/templates/scripts.py +614 -0
- scitex/vis/editor/flask_editor/templates/styles.py +549 -0
- scitex/vis/editor/flask_editor/utils.py +81 -0
- scitex/vis/io/__init__.py +84 -21
- scitex/vis/io/canvas.py +226 -0
- scitex/vis/io/data.py +204 -0
- scitex/vis/io/directory.py +202 -0
- scitex/vis/io/export.py +460 -0
- scitex/vis/io/panel.py +424 -0
- {scitex-2.4.3.dist-info → scitex-2.5.0.dist-info}/METADATA +9 -2
- {scitex-2.4.3.dist-info → scitex-2.5.0.dist-info}/RECORD +42 -21
- scitex/vis/DJANGO_INTEGRATION.md +0 -677
- scitex/vis/editor/_web_editor.py +0 -1440
- scitex/vis/tmp.txt +0 -239
- {scitex-2.4.3.dist-info → scitex-2.5.0.dist-info}/WHEEL +0 -0
- {scitex-2.4.3.dist-info → scitex-2.5.0.dist-info}/entry_points.txt +0 -0
- {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
|
scitex/vis/io/export.py
ADDED
|
@@ -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
|