matlab-mcp-python 1.0.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.
- matlab_mcp/__init__.py +2 -0
- matlab_mcp/config.py +212 -0
- matlab_mcp/jobs/__init__.py +0 -0
- matlab_mcp/jobs/executor.py +366 -0
- matlab_mcp/jobs/models.py +95 -0
- matlab_mcp/jobs/tracker.py +95 -0
- matlab_mcp/matlab_helpers/mcp_checkcode.m +17 -0
- matlab_mcp/matlab_helpers/mcp_extract_props.m +440 -0
- matlab_mcp/matlab_helpers/mcp_progress.m +16 -0
- matlab_mcp/output/__init__.py +0 -0
- matlab_mcp/output/formatter.py +234 -0
- matlab_mcp/output/plotly_convert.py +59 -0
- matlab_mcp/output/plotly_style_mapper.py +552 -0
- matlab_mcp/output/thumbnail.py +69 -0
- matlab_mcp/pool/__init__.py +0 -0
- matlab_mcp/pool/engine.py +216 -0
- matlab_mcp/pool/manager.py +227 -0
- matlab_mcp/security/__init__.py +0 -0
- matlab_mcp/security/validator.py +154 -0
- matlab_mcp/server.py +755 -0
- matlab_mcp/session/__init__.py +0 -0
- matlab_mcp/session/manager.py +210 -0
- matlab_mcp/tools/__init__.py +0 -0
- matlab_mcp/tools/admin.py +28 -0
- matlab_mcp/tools/core.py +144 -0
- matlab_mcp/tools/custom.py +241 -0
- matlab_mcp/tools/discovery.py +137 -0
- matlab_mcp/tools/files.py +456 -0
- matlab_mcp/tools/jobs.py +184 -0
- matlab_mcp/tools/monitoring.py +50 -0
- matlab_mcp_python-1.0.0.dist-info/METADATA +779 -0
- matlab_mcp_python-1.0.0.dist-info/RECORD +35 -0
- matlab_mcp_python-1.0.0.dist-info/WHEEL +4 -0
- matlab_mcp_python-1.0.0.dist-info/entry_points.txt +2 -0
- matlab_mcp_python-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
"""MATLAB figure property to Plotly JSON mapper.
|
|
2
|
+
|
|
3
|
+
Converts raw MATLAB figure property dicts (from ``mcp_extract_props.m``)
|
|
4
|
+
into Plotly figure dicts suitable for ``Plotly.newPlot()``.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import math
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Mapping tables
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
LINE_STYLE_MAP: dict[str, str] = {
|
|
19
|
+
"-": "solid",
|
|
20
|
+
"--": "dash",
|
|
21
|
+
":": "dot",
|
|
22
|
+
"-.": "dashdot",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
MARKER_MAP: dict[str, Optional[str]] = {
|
|
26
|
+
"o": "circle",
|
|
27
|
+
"+": "cross",
|
|
28
|
+
"*": "star",
|
|
29
|
+
".": "circle",
|
|
30
|
+
"x": "x",
|
|
31
|
+
"s": "square",
|
|
32
|
+
"d": "diamond",
|
|
33
|
+
"^": "triangle-up",
|
|
34
|
+
"v": "triangle-down",
|
|
35
|
+
"<": "triangle-left",
|
|
36
|
+
">": "triangle-right",
|
|
37
|
+
"p": "pentagon",
|
|
38
|
+
"h": "hexagon",
|
|
39
|
+
"none": None,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
LEGEND_LOCATION_MAP: dict[str, dict] = {
|
|
43
|
+
"northeast": {"x": 1, "y": 1, "xanchor": "right", "yanchor": "top"},
|
|
44
|
+
"northwest": {"x": 0, "y": 1, "xanchor": "left", "yanchor": "top"},
|
|
45
|
+
"southeast": {"x": 1, "y": 0, "xanchor": "right", "yanchor": "bottom"},
|
|
46
|
+
"southwest": {"x": 0, "y": 0, "xanchor": "left", "yanchor": "bottom"},
|
|
47
|
+
"north": {"x": 0.5, "y": 1, "xanchor": "center", "yanchor": "top"},
|
|
48
|
+
"south": {"x": 0.5, "y": 0, "xanchor": "center", "yanchor": "bottom"},
|
|
49
|
+
"east": {"x": 1, "y": 0.5, "xanchor": "right", "yanchor": "middle"},
|
|
50
|
+
"west": {"x": 0, "y": 0.5, "xanchor": "left", "yanchor": "middle"},
|
|
51
|
+
"best": {},
|
|
52
|
+
"bestoutside": {"x": 1.05, "y": 1, "xanchor": "left", "yanchor": "top"},
|
|
53
|
+
"northoutside": {"x": 0.5, "y": 1.05, "xanchor": "center", "yanchor": "bottom"},
|
|
54
|
+
"southoutside": {"x": 0.5, "y": -0.1, "xanchor": "center", "yanchor": "top"},
|
|
55
|
+
"eastoutside": {"x": 1.05, "y": 0.5, "xanchor": "left", "yanchor": "middle"},
|
|
56
|
+
"westoutside": {"x": -0.15, "y": 0.5, "xanchor": "right", "yanchor": "middle"},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
COLORMAP_MAP: dict[str, str] = {
|
|
60
|
+
"parula": "Viridis",
|
|
61
|
+
"jet": "Jet",
|
|
62
|
+
"hsv": "HSV",
|
|
63
|
+
"hot": "Hot",
|
|
64
|
+
"cool": "Bluered",
|
|
65
|
+
"gray": "Greys",
|
|
66
|
+
"bone": "Greys",
|
|
67
|
+
"copper": "Copper",
|
|
68
|
+
"turbo": "Turbo",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
GRID_STYLE_MAP: dict[str, Optional[str]] = {
|
|
72
|
+
"-": "solid",
|
|
73
|
+
"--": "dash",
|
|
74
|
+
":": "dot",
|
|
75
|
+
"-.": "dashdot",
|
|
76
|
+
"none": None,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Utility functions
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def rgb_to_css(rgb: list[float]) -> str:
|
|
85
|
+
"""Convert MATLAB [r, g, b] (0-1 range) to CSS ``rgb(R, G, B)``."""
|
|
86
|
+
r = round(rgb[0] * 255)
|
|
87
|
+
g = round(rgb[1] * 255)
|
|
88
|
+
b = round(rgb[2] * 255)
|
|
89
|
+
return f"rgb({r}, {g}, {b})"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def resolve_color(value: Any, fallback: Optional[str]) -> Optional[str]:
|
|
93
|
+
"""Resolve a MATLAB color value to a CSS color string.
|
|
94
|
+
|
|
95
|
+
- list/tuple of floats -> rgb_to_css
|
|
96
|
+
- ``"auto"`` -> *fallback*
|
|
97
|
+
- ``"none"`` -> ``"rgba(0,0,0,0)"``
|
|
98
|
+
"""
|
|
99
|
+
if isinstance(value, (list, tuple)):
|
|
100
|
+
return rgb_to_css(value)
|
|
101
|
+
if isinstance(value, str):
|
|
102
|
+
low = value.lower()
|
|
103
|
+
if low == "none":
|
|
104
|
+
return "rgba(0,0,0,0)"
|
|
105
|
+
if low == "auto":
|
|
106
|
+
return fallback
|
|
107
|
+
return fallback
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def map_font(font_name: str) -> str:
|
|
111
|
+
"""Build a CSS font-family stack from a MATLAB font name."""
|
|
112
|
+
if " " in font_name:
|
|
113
|
+
font_name = f'"{font_name}"'
|
|
114
|
+
return f"{font_name}, Arial, sans-serif"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Trace converters
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
# Use WebGL (scattergl) for large datasets — much faster rendering
|
|
122
|
+
_WEBGL_THRESHOLD = 10000
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _scatter_type(child: dict) -> str:
|
|
126
|
+
"""Return 'scattergl' for large datasets, 'scatter' otherwise."""
|
|
127
|
+
n = len(child.get("xdata", []))
|
|
128
|
+
return "scattergl" if n > _WEBGL_THRESHOLD else "scatter"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def convert_line(child: dict, axis_suffix: str) -> dict:
|
|
132
|
+
"""Convert a MATLAB line child to a Plotly scatter trace."""
|
|
133
|
+
marker_symbol = MARKER_MAP.get(child.get("marker", "none"))
|
|
134
|
+
mode = "lines+markers" if marker_symbol else "lines"
|
|
135
|
+
line_color = resolve_color(child.get("color"), None)
|
|
136
|
+
|
|
137
|
+
trace: dict[str, Any] = {
|
|
138
|
+
"type": _scatter_type(child),
|
|
139
|
+
"mode": mode,
|
|
140
|
+
"x": child.get("xdata", []),
|
|
141
|
+
"y": child.get("ydata", []),
|
|
142
|
+
"line": {
|
|
143
|
+
"color": line_color,
|
|
144
|
+
"width": child.get("line_width", 1),
|
|
145
|
+
"dash": LINE_STYLE_MAP.get(child.get("line_style", "-"), "solid"),
|
|
146
|
+
},
|
|
147
|
+
"xaxis": f"x{axis_suffix}",
|
|
148
|
+
"yaxis": f"y{axis_suffix}",
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
name = child.get("display_name", "")
|
|
152
|
+
if name:
|
|
153
|
+
trace["name"] = name
|
|
154
|
+
else:
|
|
155
|
+
trace["showlegend"] = False
|
|
156
|
+
|
|
157
|
+
if marker_symbol:
|
|
158
|
+
face_color = resolve_color(child.get("marker_face_color"), line_color)
|
|
159
|
+
edge_color = resolve_color(child.get("marker_edge_color"), line_color)
|
|
160
|
+
trace["marker"] = {
|
|
161
|
+
"symbol": marker_symbol,
|
|
162
|
+
"size": child.get("marker_size", 6),
|
|
163
|
+
"color": face_color,
|
|
164
|
+
"line": {"color": edge_color, "width": 1},
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return trace
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def convert_bar(child: dict, axis_suffix: str) -> dict:
|
|
171
|
+
"""Convert a MATLAB bar child to a Plotly bar trace."""
|
|
172
|
+
face_color = resolve_color(child.get("face_color"), None)
|
|
173
|
+
edge_color = resolve_color(child.get("edge_color"), face_color)
|
|
174
|
+
|
|
175
|
+
trace: dict[str, Any] = {
|
|
176
|
+
"type": "bar",
|
|
177
|
+
"x": child.get("xdata", []),
|
|
178
|
+
"y": child.get("ydata", []),
|
|
179
|
+
"width": child.get("bar_width", 0.8),
|
|
180
|
+
"marker": {
|
|
181
|
+
"color": face_color,
|
|
182
|
+
"line": {"color": edge_color, "width": 1},
|
|
183
|
+
},
|
|
184
|
+
"xaxis": f"x{axis_suffix}",
|
|
185
|
+
"yaxis": f"y{axis_suffix}",
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
name = child.get("display_name", "")
|
|
189
|
+
if name:
|
|
190
|
+
trace["name"] = name
|
|
191
|
+
else:
|
|
192
|
+
trace["showlegend"] = False
|
|
193
|
+
|
|
194
|
+
return trace
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def convert_scatter_trace(child: dict, axis_suffix: str) -> dict:
|
|
198
|
+
"""Convert a MATLAB scatter child to a Plotly scatter trace."""
|
|
199
|
+
face_color = resolve_color(child.get("marker_face_color"), None)
|
|
200
|
+
edge_color = resolve_color(child.get("marker_edge_color"), face_color)
|
|
201
|
+
marker_symbol = MARKER_MAP.get(child.get("marker", "o"), "circle")
|
|
202
|
+
|
|
203
|
+
# MATLAB SizeData is area in pt^2; Plotly marker.size is diameter
|
|
204
|
+
size_data = child.get("size_data", 36)
|
|
205
|
+
plotly_size = round(math.sqrt(size_data))
|
|
206
|
+
|
|
207
|
+
trace: dict[str, Any] = {
|
|
208
|
+
"type": _scatter_type(child),
|
|
209
|
+
"mode": "markers",
|
|
210
|
+
"x": child.get("xdata", []),
|
|
211
|
+
"y": child.get("ydata", []),
|
|
212
|
+
"marker": {
|
|
213
|
+
"symbol": marker_symbol,
|
|
214
|
+
"size": plotly_size,
|
|
215
|
+
"color": face_color,
|
|
216
|
+
"line": {"color": edge_color, "width": 1},
|
|
217
|
+
},
|
|
218
|
+
"xaxis": f"x{axis_suffix}",
|
|
219
|
+
"yaxis": f"y{axis_suffix}",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
name = child.get("display_name", "")
|
|
223
|
+
if name:
|
|
224
|
+
trace["name"] = name
|
|
225
|
+
else:
|
|
226
|
+
trace["showlegend"] = False
|
|
227
|
+
|
|
228
|
+
return trace
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def convert_surface(child: dict, axis_suffix: str) -> dict:
|
|
232
|
+
"""Convert a MATLAB surface child to a Plotly surface trace."""
|
|
233
|
+
colormap = child.get("colormap", "parula")
|
|
234
|
+
colorscale = COLORMAP_MAP.get(colormap, "Viridis")
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
"type": "surface",
|
|
238
|
+
"x": child.get("xdata", []),
|
|
239
|
+
"y": child.get("ydata", []),
|
|
240
|
+
"z": child.get("zdata", []),
|
|
241
|
+
"colorscale": colorscale,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def convert_heatmap(child: dict, axis_suffix: str) -> dict:
|
|
246
|
+
"""Convert a MATLAB image child to a Plotly heatmap trace."""
|
|
247
|
+
colormap = child.get("colormap", "gray")
|
|
248
|
+
colorscale = COLORMAP_MAP.get(colormap, "Greys")
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
"type": "heatmap",
|
|
252
|
+
"z": child.get("cdata", []),
|
|
253
|
+
"colorscale": colorscale,
|
|
254
|
+
"xaxis": f"x{axis_suffix}",
|
|
255
|
+
"yaxis": f"y{axis_suffix}",
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def convert_histogram_trace(child: dict, axis_suffix: str) -> dict:
|
|
260
|
+
"""Convert a MATLAB histogram child to a Plotly histogram trace."""
|
|
261
|
+
face_color = resolve_color(child.get("face_color"), None)
|
|
262
|
+
edge_color = resolve_color(child.get("edge_color"), face_color)
|
|
263
|
+
|
|
264
|
+
trace: dict[str, Any] = {
|
|
265
|
+
"type": "histogram",
|
|
266
|
+
"x": child.get("data", []),
|
|
267
|
+
"marker": {
|
|
268
|
+
"color": face_color,
|
|
269
|
+
"line": {"color": edge_color, "width": 1},
|
|
270
|
+
},
|
|
271
|
+
"xaxis": f"x{axis_suffix}",
|
|
272
|
+
"yaxis": f"y{axis_suffix}",
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
bin_edges = child.get("bin_edges")
|
|
276
|
+
if bin_edges and len(bin_edges) >= 2:
|
|
277
|
+
trace["xbins"] = {
|
|
278
|
+
"start": bin_edges[0],
|
|
279
|
+
"end": bin_edges[-1],
|
|
280
|
+
"size": bin_edges[1] - bin_edges[0],
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return trace
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def convert_patch(child: dict, axis_suffix: str) -> dict:
|
|
287
|
+
"""Convert a MATLAB patch child to a Plotly scatter trace with fill."""
|
|
288
|
+
face_color_raw = child.get("face_color", [0.8, 0.8, 0.8])
|
|
289
|
+
face_alpha = child.get("face_alpha", 1.0)
|
|
290
|
+
edge_color = resolve_color(child.get("edge_color"), None)
|
|
291
|
+
|
|
292
|
+
# Build fillcolor with alpha
|
|
293
|
+
if isinstance(face_color_raw, (list, tuple)):
|
|
294
|
+
r = round(face_color_raw[0] * 255)
|
|
295
|
+
g = round(face_color_raw[1] * 255)
|
|
296
|
+
b = round(face_color_raw[2] * 255)
|
|
297
|
+
fillcolor = f"rgba({r},{g},{b},{face_alpha})"
|
|
298
|
+
else:
|
|
299
|
+
fillcolor = resolve_color(face_color_raw, "rgba(128,128,128,0.5)")
|
|
300
|
+
|
|
301
|
+
# Close the polygon by repeating the first point
|
|
302
|
+
xdata = list(child.get("xdata", []))
|
|
303
|
+
ydata = list(child.get("ydata", []))
|
|
304
|
+
if xdata and ydata and (xdata[0] != xdata[-1] or ydata[0] != ydata[-1]):
|
|
305
|
+
xdata.append(xdata[0])
|
|
306
|
+
ydata.append(ydata[0])
|
|
307
|
+
|
|
308
|
+
trace: dict[str, Any] = {
|
|
309
|
+
"type": "scatter",
|
|
310
|
+
"mode": "lines",
|
|
311
|
+
"fill": "toself",
|
|
312
|
+
"fillcolor": fillcolor,
|
|
313
|
+
"x": xdata,
|
|
314
|
+
"y": ydata,
|
|
315
|
+
"line": {"color": edge_color or "rgba(0,0,0,0)", "width": 1},
|
|
316
|
+
"xaxis": f"x{axis_suffix}",
|
|
317
|
+
"yaxis": f"y{axis_suffix}",
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
name = child.get("display_name", "")
|
|
321
|
+
if name:
|
|
322
|
+
trace["name"] = name
|
|
323
|
+
else:
|
|
324
|
+
trace["showlegend"] = False
|
|
325
|
+
|
|
326
|
+
return trace
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
# Child type dispatcher
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
_CHILD_CONVERTERS: dict[str, Any] = {
|
|
334
|
+
"line": convert_line,
|
|
335
|
+
"bar": convert_bar,
|
|
336
|
+
"scatter": convert_scatter_trace,
|
|
337
|
+
"surface": convert_surface,
|
|
338
|
+
"image": convert_heatmap,
|
|
339
|
+
"histogram": convert_histogram_trace,
|
|
340
|
+
"patch": convert_patch,
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ---------------------------------------------------------------------------
|
|
345
|
+
# Layout
|
|
346
|
+
# ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
def compute_domains(
|
|
349
|
+
grid: Optional[dict], axes_list: list[dict]
|
|
350
|
+
) -> list[dict[str, list[float]]]:
|
|
351
|
+
"""Compute Plotly xaxis/yaxis domain pairs from grid positions.
|
|
352
|
+
|
|
353
|
+
Returns a list parallel to *axes_list*.
|
|
354
|
+
"""
|
|
355
|
+
if grid is None:
|
|
356
|
+
return [{"x": [0, 1], "y": [0, 1]}]
|
|
357
|
+
|
|
358
|
+
rows = grid["rows"]
|
|
359
|
+
cols = grid["cols"]
|
|
360
|
+
gap_x, gap_y = 0.04, 0.06
|
|
361
|
+
|
|
362
|
+
domains: list[dict[str, list[float]]] = []
|
|
363
|
+
for ax in axes_list:
|
|
364
|
+
gi = ax["grid_index"]
|
|
365
|
+
col_start = (gi["col"] - 1) / cols
|
|
366
|
+
col_end = (gi["col"] - 1 + gi["colspan"]) / cols
|
|
367
|
+
row_start = (gi["row"] - 1) / rows
|
|
368
|
+
row_end = (gi["row"] - 1 + gi["rowspan"]) / rows
|
|
369
|
+
|
|
370
|
+
x_domain = [max(0, col_start + gap_x / 2), min(1, col_end - gap_x / 2)]
|
|
371
|
+
y_domain = [max(0, 1 - row_end + gap_y / 2), min(1, 1 - row_start - gap_y / 2)]
|
|
372
|
+
domains.append({"x": x_domain, "y": y_domain})
|
|
373
|
+
|
|
374
|
+
return domains
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _axis_suffix(index: int) -> str:
|
|
378
|
+
"""Return '' for index 0, '2' for 1, '3' for 2, etc."""
|
|
379
|
+
return "" if index == 0 else str(index + 1)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _build_axis_layout(axes_data: dict, suffix: str) -> dict:
|
|
383
|
+
"""Build xaxis/yaxis layout dicts from MATLAB axes properties."""
|
|
384
|
+
grid_color_rgb = axes_data.get("grid_color", [0.15, 0.15, 0.15])
|
|
385
|
+
grid_alpha = axes_data.get("grid_alpha", 0.15)
|
|
386
|
+
r, g, b = [round(c * 255) for c in grid_color_rgb]
|
|
387
|
+
grid_color = f"rgba({r},{g},{b},{grid_alpha})"
|
|
388
|
+
grid_dash = GRID_STYLE_MAP.get(axes_data.get("grid_line_style", "-"), "solid")
|
|
389
|
+
|
|
390
|
+
tick_font = axes_data.get("tick_font", {})
|
|
391
|
+
tick_font_dict: dict[str, Any] = {}
|
|
392
|
+
if tick_font.get("font_name"):
|
|
393
|
+
tick_font_dict["family"] = map_font(tick_font["font_name"])
|
|
394
|
+
if tick_font.get("font_size"):
|
|
395
|
+
tick_font_dict["size"] = tick_font["font_size"]
|
|
396
|
+
|
|
397
|
+
def _label_dict(label_data: Optional[dict]) -> dict:
|
|
398
|
+
if not label_data or not label_data.get("text"):
|
|
399
|
+
return {}
|
|
400
|
+
result: dict[str, Any] = {"text": label_data["text"]}
|
|
401
|
+
font: dict[str, Any] = {}
|
|
402
|
+
if label_data.get("font_name"):
|
|
403
|
+
font["family"] = map_font(label_data["font_name"])
|
|
404
|
+
if label_data.get("font_size"):
|
|
405
|
+
font["size"] = label_data["font_size"]
|
|
406
|
+
if font:
|
|
407
|
+
result["font"] = font
|
|
408
|
+
return result
|
|
409
|
+
|
|
410
|
+
x_key = f"xaxis{suffix}"
|
|
411
|
+
y_key = f"yaxis{suffix}"
|
|
412
|
+
|
|
413
|
+
xlim = axes_data.get("xlim")
|
|
414
|
+
ylim = axes_data.get("ylim")
|
|
415
|
+
|
|
416
|
+
layout: dict[str, Any] = {}
|
|
417
|
+
|
|
418
|
+
layout[x_key] = {
|
|
419
|
+
"showgrid": axes_data.get("xgrid", False),
|
|
420
|
+
"gridcolor": grid_color,
|
|
421
|
+
"griddash": grid_dash,
|
|
422
|
+
}
|
|
423
|
+
x_title = _label_dict(axes_data.get("xlabel"))
|
|
424
|
+
if x_title:
|
|
425
|
+
layout[x_key]["title"] = x_title
|
|
426
|
+
if xlim:
|
|
427
|
+
layout[x_key]["range"] = xlim
|
|
428
|
+
xtick = axes_data.get("xtick")
|
|
429
|
+
if xtick:
|
|
430
|
+
layout[x_key]["tickvals"] = xtick
|
|
431
|
+
xticklabels = axes_data.get("xticklabels")
|
|
432
|
+
if xticklabels:
|
|
433
|
+
layout[x_key]["ticktext"] = xticklabels
|
|
434
|
+
if tick_font_dict:
|
|
435
|
+
layout[x_key]["tickfont"] = tick_font_dict
|
|
436
|
+
if axes_data.get("xdir") == "reverse":
|
|
437
|
+
layout[x_key]["autorange"] = "reversed"
|
|
438
|
+
|
|
439
|
+
layout[y_key] = {
|
|
440
|
+
"showgrid": axes_data.get("ygrid", False),
|
|
441
|
+
"gridcolor": grid_color,
|
|
442
|
+
"griddash": grid_dash,
|
|
443
|
+
}
|
|
444
|
+
y_title = _label_dict(axes_data.get("ylabel"))
|
|
445
|
+
if y_title:
|
|
446
|
+
layout[y_key]["title"] = y_title
|
|
447
|
+
if ylim:
|
|
448
|
+
layout[y_key]["range"] = ylim
|
|
449
|
+
ytick = axes_data.get("ytick")
|
|
450
|
+
if ytick:
|
|
451
|
+
layout[y_key]["tickvals"] = ytick
|
|
452
|
+
yticklabels = axes_data.get("yticklabels")
|
|
453
|
+
if yticklabels:
|
|
454
|
+
layout[y_key]["ticktext"] = yticklabels
|
|
455
|
+
if tick_font_dict:
|
|
456
|
+
layout[y_key]["tickfont"] = tick_font_dict
|
|
457
|
+
if axes_data.get("ydir") == "reverse":
|
|
458
|
+
layout[y_key]["autorange"] = "reversed"
|
|
459
|
+
|
|
460
|
+
# Link y-axis to its x-axis for multi-axis subplots
|
|
461
|
+
if suffix:
|
|
462
|
+
layout[y_key]["anchor"] = f"x{suffix}"
|
|
463
|
+
|
|
464
|
+
return layout
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def convert_axes(axes_data: dict, axis_index: int) -> tuple[list[dict], dict]:
|
|
468
|
+
"""Convert a single MATLAB axes dict to Plotly traces + layout fragment."""
|
|
469
|
+
suffix = _axis_suffix(axis_index)
|
|
470
|
+
traces: list[dict] = []
|
|
471
|
+
|
|
472
|
+
for child in axes_data.get("children", []):
|
|
473
|
+
child_type = child.get("type", "")
|
|
474
|
+
converter = _CHILD_CONVERTERS.get(child_type)
|
|
475
|
+
if converter:
|
|
476
|
+
traces.append(converter(child, suffix))
|
|
477
|
+
else:
|
|
478
|
+
logger.warning("Unknown child type %r — skipping", child_type)
|
|
479
|
+
|
|
480
|
+
layout_frag = _build_axis_layout(axes_data, suffix)
|
|
481
|
+
|
|
482
|
+
return traces, layout_frag
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def convert_figure(matlab_fig: dict) -> dict:
|
|
486
|
+
"""Convert a full MATLAB figure property dict to a Plotly figure dict."""
|
|
487
|
+
axes_list = matlab_fig.get("axes", [])
|
|
488
|
+
layout_type = matlab_fig.get("layout_type", "single")
|
|
489
|
+
grid = matlab_fig.get("grid") if layout_type != "single" else None
|
|
490
|
+
|
|
491
|
+
all_traces: list[dict] = []
|
|
492
|
+
merged_layout: dict[str, Any] = {}
|
|
493
|
+
|
|
494
|
+
# Background colors and global font color
|
|
495
|
+
bg = matlab_fig.get("background_color", [0.94, 0.94, 0.94])
|
|
496
|
+
merged_layout["paper_bgcolor"] = rgb_to_css(bg)
|
|
497
|
+
|
|
498
|
+
# Derive font color from background brightness (dark bg -> light text)
|
|
499
|
+
bg_luminance = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2]
|
|
500
|
+
font_color = "rgb(204, 204, 204)" if bg_luminance < 0.5 else "rgb(34, 34, 34)"
|
|
501
|
+
merged_layout["font"] = {"color": font_color}
|
|
502
|
+
|
|
503
|
+
# Compute subplot domains
|
|
504
|
+
domains = compute_domains(grid, axes_list)
|
|
505
|
+
|
|
506
|
+
show_legend = False
|
|
507
|
+
|
|
508
|
+
for i, axes_data in enumerate(axes_list):
|
|
509
|
+
traces, layout_frag = convert_axes(axes_data, i)
|
|
510
|
+
all_traces.extend(traces)
|
|
511
|
+
merged_layout.update(layout_frag)
|
|
512
|
+
|
|
513
|
+
suffix = _axis_suffix(i)
|
|
514
|
+
x_key = f"xaxis{suffix}"
|
|
515
|
+
y_key = f"yaxis{suffix}"
|
|
516
|
+
|
|
517
|
+
# Apply domains for multi-axes
|
|
518
|
+
if len(axes_list) > 1:
|
|
519
|
+
if x_key in merged_layout:
|
|
520
|
+
merged_layout[x_key]["domain"] = domains[i]["x"]
|
|
521
|
+
if y_key in merged_layout:
|
|
522
|
+
merged_layout[y_key]["domain"] = domains[i]["y"]
|
|
523
|
+
|
|
524
|
+
# Axes background
|
|
525
|
+
axes_bg = axes_data.get("color", [1, 1, 1])
|
|
526
|
+
if i == 0:
|
|
527
|
+
merged_layout["plot_bgcolor"] = rgb_to_css(axes_bg)
|
|
528
|
+
|
|
529
|
+
# Title from first axes
|
|
530
|
+
title_data = axes_data.get("title", {})
|
|
531
|
+
if i == 0 and title_data.get("text"):
|
|
532
|
+
title_dict: dict[str, Any] = {"text": title_data["text"]}
|
|
533
|
+
font: dict[str, Any] = {}
|
|
534
|
+
if title_data.get("font_name"):
|
|
535
|
+
font["family"] = map_font(title_data["font_name"])
|
|
536
|
+
if title_data.get("font_size"):
|
|
537
|
+
font["size"] = title_data["font_size"]
|
|
538
|
+
if font:
|
|
539
|
+
title_dict["font"] = font
|
|
540
|
+
merged_layout["title"] = title_dict
|
|
541
|
+
|
|
542
|
+
# Legend
|
|
543
|
+
legend_data = axes_data.get("legend", {})
|
|
544
|
+
if legend_data.get("visible"):
|
|
545
|
+
show_legend = True
|
|
546
|
+
location = legend_data.get("location", "best")
|
|
547
|
+
legend_pos = LEGEND_LOCATION_MAP.get(location, {})
|
|
548
|
+
merged_layout["legend"] = legend_pos
|
|
549
|
+
|
|
550
|
+
merged_layout["showlegend"] = show_legend
|
|
551
|
+
|
|
552
|
+
return {"data": all_traces, "layout": merged_layout}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Thumbnail generation for MATLAB figure images.
|
|
2
|
+
|
|
3
|
+
Provides ``generate_thumbnail`` which resizes an image using Pillow and
|
|
4
|
+
returns a base64-encoded PNG string suitable for embedding in MCP responses.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import io
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def generate_thumbnail(
|
|
18
|
+
image_path: str,
|
|
19
|
+
max_width: int = 400,
|
|
20
|
+
) -> Optional[str]:
|
|
21
|
+
"""Generate a base64-encoded PNG thumbnail from *image_path*.
|
|
22
|
+
|
|
23
|
+
Resizes the image to at most *max_width* pixels wide while preserving the
|
|
24
|
+
aspect ratio. Returns ``None`` if the image cannot be read or resized.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
image_path:
|
|
29
|
+
Absolute path to the source image file (PNG, JPG, etc.).
|
|
30
|
+
max_width:
|
|
31
|
+
Maximum width in pixels for the thumbnail. Defaults to 400.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
Optional[str]
|
|
36
|
+
Base64-encoded PNG string, or ``None`` on failure.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
from PIL import Image # type: ignore[import]
|
|
40
|
+
except ImportError:
|
|
41
|
+
logger.warning("Pillow is not installed; thumbnail generation disabled")
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
path = Path(image_path)
|
|
45
|
+
if not path.exists():
|
|
46
|
+
logger.debug("Image path does not exist: %s", image_path)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
with Image.open(path) as img:
|
|
51
|
+
# Convert to RGB if necessary (handles RGBA, palette modes, etc.)
|
|
52
|
+
if img.mode not in ("RGB", "L"):
|
|
53
|
+
img = img.convert("RGB")
|
|
54
|
+
|
|
55
|
+
width, height = img.size
|
|
56
|
+
if width > max_width:
|
|
57
|
+
ratio = max_width / width
|
|
58
|
+
new_height = max(1, int(height * ratio))
|
|
59
|
+
img = img.resize((max_width, new_height), Image.LANCZOS)
|
|
60
|
+
|
|
61
|
+
# Encode to PNG in memory
|
|
62
|
+
buf = io.BytesIO()
|
|
63
|
+
img.save(buf, format="PNG")
|
|
64
|
+
buf.seek(0)
|
|
65
|
+
return base64.b64encode(buf.read()).decode("ascii")
|
|
66
|
+
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
logger.warning("Failed to generate thumbnail for %s: %s", image_path, exc)
|
|
69
|
+
return None
|
|
File without changes
|