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.
@@ -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