py2max 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. py2max/__init__.py +67 -0
  2. py2max/__main__.py +6 -0
  3. py2max/cli.py +1251 -0
  4. py2max/core/__init__.py +39 -0
  5. py2max/core/abstract.py +146 -0
  6. py2max/core/box.py +231 -0
  7. py2max/core/common.py +19 -0
  8. py2max/core/patcher.py +1658 -0
  9. py2max/core/patchline.py +68 -0
  10. py2max/exceptions.py +385 -0
  11. py2max/export/__init__.py +20 -0
  12. py2max/export/converters.py +345 -0
  13. py2max/export/svg.py +393 -0
  14. py2max/layout/__init__.py +26 -0
  15. py2max/layout/base.py +463 -0
  16. py2max/layout/flow.py +405 -0
  17. py2max/layout/grid.py +374 -0
  18. py2max/layout/matrix.py +628 -0
  19. py2max/log.py +338 -0
  20. py2max/maxref/__init__.py +78 -0
  21. py2max/maxref/category.py +163 -0
  22. py2max/maxref/db.py +1082 -0
  23. py2max/maxref/legacy.py +324 -0
  24. py2max/maxref/parser.py +703 -0
  25. py2max/py.typed +0 -0
  26. py2max/server/__init__.py +54 -0
  27. py2max/server/client.py +295 -0
  28. py2max/server/inline.py +312 -0
  29. py2max/server/repl.py +561 -0
  30. py2max/server/rpc.py +240 -0
  31. py2max/server/websocket.py +997 -0
  32. py2max/static/cola.min.js +4 -0
  33. py2max/static/d3.v7.min.js +2 -0
  34. py2max/static/dagre-bundle.js +328 -0
  35. py2max/static/elk.bundled.js +6663 -0
  36. py2max/static/index.html +168 -0
  37. py2max/static/interactive.html +589 -0
  38. py2max/static/interactive.js +2111 -0
  39. py2max/static/live-preview.js +324 -0
  40. py2max/static/svg.min.js +13 -0
  41. py2max/static/svg.min.js.map +1 -0
  42. py2max/transformers.py +168 -0
  43. py2max/utils.py +83 -0
  44. py2max-0.2.1.dist-info/METADATA +390 -0
  45. py2max-0.2.1.dist-info/RECORD +48 -0
  46. py2max-0.2.1.dist-info/WHEEL +4 -0
  47. py2max-0.2.1.dist-info/entry_points.txt +3 -0
  48. py2max-0.2.1.dist-info/licenses/LICENSE +19 -0
py2max/export/svg.py ADDED
@@ -0,0 +1,393 @@
1
+ """SVG export functionality for py2max patches.
2
+
3
+ This module provides functionality to export Max/MSP patch layouts to SVG format,
4
+ enabling offline visual validation without requiring Max.
5
+
6
+ Features:
7
+ - Renders boxes with correct positioning and sizing
8
+ - Draws patchlines with connection points
9
+ - Supports different box types (comments, messages, objects)
10
+ - Generates clean, scalable SVG output
11
+
12
+ Example:
13
+ >>> from py2max import Patcher
14
+ >>> from py2max.svg import export_svg
15
+ >>> p = Patcher('my-patch.maxpat')
16
+ >>> osc = p.add_textbox('cycle~ 440')
17
+ >>> dac = p.add_textbox('ezdac~')
18
+ >>> p.add_line(osc, dac)
19
+ >>> export_svg(p, '/tmp/my-patch.svg')
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import html
25
+ from pathlib import Path
26
+ from typing import TYPE_CHECKING, Optional, Union
27
+
28
+ if TYPE_CHECKING:
29
+ from ..core import Box, Patcher
30
+
31
+
32
+ # SVG styling constants
33
+ BOX_FILL = "#f0f0f0"
34
+ BOX_STROKE = "#333333"
35
+ BOX_STROKE_WIDTH = 1
36
+ COMMENT_FILL = "#ffffd0"
37
+ MESSAGE_FILL = "#e0e0e0"
38
+ TEXT_COLOR = "#000000"
39
+ TEXT_FONT_FAMILY = "Monaco, Courier, monospace"
40
+ TEXT_FONT_SIZE = 12
41
+ LINE_COLOR = "#666666"
42
+ LINE_WIDTH = 2
43
+ INLET_COLOR = "#4080ff"
44
+ OUTLET_COLOR = "#ff8040"
45
+ PORT_RADIUS = 3
46
+ PADDING = 20
47
+
48
+
49
+ def _escape_text(text: str) -> str:
50
+ """Escape text for SVG."""
51
+ return html.escape(str(text))
52
+
53
+
54
+ def _get_box_fill(box: Box) -> str:
55
+ """Determine fill color based on box type."""
56
+ maxclass = getattr(box, "maxclass", "newobj")
57
+ if maxclass == "comment":
58
+ return COMMENT_FILL
59
+ elif maxclass == "message":
60
+ return MESSAGE_FILL
61
+ return BOX_FILL
62
+
63
+
64
+ def _get_box_text(box: Box) -> str:
65
+ """Extract display text from a box."""
66
+ text = getattr(box, "text", None)
67
+ if text:
68
+ return str(text)
69
+
70
+ maxclass = getattr(box, "maxclass", "newobj")
71
+ if maxclass == "comment":
72
+ return getattr(box, "text", "")
73
+
74
+ # For other object types, use maxclass name
75
+ return maxclass
76
+
77
+
78
+ def _render_box(box, show_ports: bool = True) -> str:
79
+ """Render a single box to SVG elements."""
80
+ rect = getattr(box, "patching_rect", None)
81
+ if not rect:
82
+ return ""
83
+
84
+ # Handle both Rect objects and list/tuple representations
85
+ if hasattr(rect, "x"):
86
+ x, y, w, h = rect.x, rect.y, rect.w, rect.h
87
+ elif isinstance(rect, (list, tuple)) and len(rect) >= 4:
88
+ x, y, w, h = rect[0], rect[1], rect[2], rect[3]
89
+ else:
90
+ return ""
91
+ fill = _get_box_fill(box)
92
+ text = _get_box_text(box)
93
+
94
+ svg_parts = []
95
+
96
+ # Draw box rectangle
97
+ svg_parts.append(
98
+ f'<rect x="{x}" y="{y}" width="{w}" height="{h}" '
99
+ f'fill="{fill}" stroke="{BOX_STROKE}" stroke-width="{BOX_STROKE_WIDTH}" '
100
+ f'rx="2" />'
101
+ )
102
+
103
+ # Draw text (centered vertically, with padding)
104
+ text_x = x + 5
105
+ text_y = y + h / 2 + TEXT_FONT_SIZE / 3
106
+ escaped_text = _escape_text(text)
107
+
108
+ # Note: We don't truncate text to match Max's behavior where text can overflow
109
+ # the box visually. SVG will handle rendering even if text extends beyond box bounds.
110
+
111
+ svg_parts.append(
112
+ f'<text x="{text_x}" y="{text_y}" '
113
+ f'font-family="{TEXT_FONT_FAMILY}" font-size="{TEXT_FONT_SIZE}" '
114
+ f'fill="{TEXT_COLOR}">{escaped_text}</text>'
115
+ )
116
+
117
+ # Draw inlet/outlet ports if enabled
118
+ if show_ports:
119
+ # Try to get inlet/outlet count via methods (for maxref-enabled boxes)
120
+ inlet_count = 0
121
+ outlet_count = 0
122
+
123
+ if hasattr(box, "get_inlet_count"):
124
+ try:
125
+ inlet_count = box.get_inlet_count() or 0
126
+ except Exception:
127
+ pass
128
+
129
+ if hasattr(box, "get_outlet_count"):
130
+ try:
131
+ outlet_count = box.get_outlet_count() or 0
132
+ except Exception:
133
+ pass
134
+
135
+ # Fallback to private attributes if methods don't exist
136
+ if inlet_count == 0:
137
+ inlet_count = getattr(box, "_inlet_count", 0) or 0
138
+ if outlet_count == 0:
139
+ outlet_count = getattr(box, "_outlet_count", 0) or 0
140
+
141
+ # Draw inlets at top
142
+ if inlet_count > 0:
143
+ inlet_spacing = w / (inlet_count + 1)
144
+ for i in range(inlet_count):
145
+ inlet_x = x + inlet_spacing * (i + 1)
146
+ inlet_y = y
147
+ svg_parts.append(
148
+ f'<circle cx="{inlet_x}" cy="{inlet_y}" r="{PORT_RADIUS}" '
149
+ f'fill="{INLET_COLOR}" stroke="{BOX_STROKE}" stroke-width="0.5" />'
150
+ )
151
+
152
+ # Draw outlets at bottom
153
+ if outlet_count > 0:
154
+ outlet_spacing = w / (outlet_count + 1)
155
+ for i in range(outlet_count):
156
+ outlet_x = x + outlet_spacing * (i + 1)
157
+ outlet_y = y + h
158
+ svg_parts.append(
159
+ f'<circle cx="{outlet_x}" cy="{outlet_y}" r="{PORT_RADIUS}" '
160
+ f'fill="{OUTLET_COLOR}" stroke="{BOX_STROKE}" stroke-width="0.5" />'
161
+ )
162
+
163
+ return "\n".join(svg_parts)
164
+
165
+
166
+ def _get_port_position(box, port_index: int, is_outlet: bool) -> tuple[float, float]:
167
+ """Calculate the x,y position of an inlet or outlet port."""
168
+ rect = getattr(box, "patching_rect", None)
169
+ if not rect:
170
+ return (0, 0)
171
+
172
+ # Handle both Rect objects and list/tuple representations
173
+ if hasattr(rect, "x"):
174
+ x, y, w, h = rect.x, rect.y, rect.w, rect.h
175
+ elif isinstance(rect, (list, tuple)) and len(rect) >= 4:
176
+ x, y, w, h = rect[0], rect[1], rect[2], rect[3]
177
+ else:
178
+ return (0, 0)
179
+
180
+ # Try to get port count via methods first
181
+ if is_outlet:
182
+ count = 1
183
+ if hasattr(box, "get_outlet_count"):
184
+ try:
185
+ count = box.get_outlet_count() or 1
186
+ except Exception:
187
+ count = getattr(box, "_outlet_count", 1) or 1
188
+ else:
189
+ count = getattr(box, "_outlet_count", 1) or 1
190
+
191
+ spacing = w / (count + 1)
192
+ port_x = x + spacing * (port_index + 1)
193
+ port_y = y + h
194
+ else:
195
+ count = 1
196
+ if hasattr(box, "get_inlet_count"):
197
+ try:
198
+ count = box.get_inlet_count() or 1
199
+ except Exception:
200
+ count = getattr(box, "_inlet_count", 1) or 1
201
+ else:
202
+ count = getattr(box, "_inlet_count", 1) or 1
203
+
204
+ spacing = w / (count + 1)
205
+ port_x = x + spacing * (port_index + 1)
206
+ port_y = y
207
+
208
+ return (port_x, port_y)
209
+
210
+
211
+ def _render_patchline(line, patcher: Patcher) -> str:
212
+ """Render a patchline connection to SVG."""
213
+ # Get source and destination boxes
214
+ src_id = getattr(line, "src", None)
215
+ dst_id = getattr(line, "dst", None)
216
+
217
+ if not src_id or not dst_id:
218
+ return ""
219
+
220
+ src_box = patcher._objects.get(src_id)
221
+ dst_box = patcher._objects.get(dst_id)
222
+
223
+ if not src_box or not dst_box:
224
+ return ""
225
+
226
+ # Get port indices
227
+ source = getattr(line, "source", [src_id, 0])
228
+ destination = getattr(line, "destination", [dst_id, 0])
229
+
230
+ src_port = int(source[1]) if len(source) > 1 else 0
231
+ dst_port = int(destination[1]) if len(destination) > 1 else 0
232
+
233
+ # Calculate connection points
234
+ x1, y1 = _get_port_position(src_box, src_port, is_outlet=True)
235
+ x2, y2 = _get_port_position(dst_box, dst_port, is_outlet=False)
236
+
237
+ # Draw line
238
+ return (
239
+ f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" '
240
+ f'stroke="{LINE_COLOR}" stroke-width="{LINE_WIDTH}" '
241
+ f'stroke-linecap="round" />'
242
+ )
243
+
244
+
245
+ def _calculate_viewbox(patcher: Patcher) -> tuple[float, float, float, float]:
246
+ """Calculate SVG viewBox to fit all objects with padding."""
247
+ boxes = patcher._boxes
248
+
249
+ if not boxes:
250
+ return (0, 0, 800, 600)
251
+
252
+ min_x = float("inf")
253
+ min_y = float("inf")
254
+ max_x = float("-inf")
255
+ max_y = float("-inf")
256
+
257
+ for box in boxes:
258
+ rect = getattr(box, "patching_rect", None)
259
+ if rect:
260
+ # Handle both Rect objects and list/tuple representations
261
+ if hasattr(rect, "x"):
262
+ x, y, w, h = rect.x, rect.y, rect.w, rect.h
263
+ elif isinstance(rect, (list, tuple)) and len(rect) >= 4:
264
+ x, y, w, h = rect[0], rect[1], rect[2], rect[3]
265
+ else:
266
+ continue
267
+
268
+ min_x = min(min_x, x)
269
+ min_y = min(min_y, y)
270
+ max_x = max(max_x, x + w)
271
+ max_y = max(max_y, y + h)
272
+
273
+ # Add padding
274
+ min_x -= PADDING
275
+ min_y -= PADDING
276
+ max_x += PADDING
277
+ max_y += PADDING
278
+
279
+ width = max_x - min_x
280
+ height = max_y - min_y
281
+
282
+ return (min_x, min_y, width, height)
283
+
284
+
285
+ def export_svg(
286
+ patcher: Patcher,
287
+ output_path: Union[str, Path],
288
+ show_ports: bool = True,
289
+ title: Optional[str] = None,
290
+ ) -> None:
291
+ """Export a patcher to SVG format.
292
+
293
+ Args:
294
+ patcher: The Patcher object to export.
295
+ output_path: Output file path for the SVG.
296
+ show_ports: Whether to show inlet/outlet ports on boxes.
297
+ title: Optional title to display at top of SVG.
298
+
299
+ Example:
300
+ >>> p = Patcher('test.maxpat')
301
+ >>> osc = p.add_textbox('cycle~ 440')
302
+ >>> dac = p.add_textbox('ezdac~')
303
+ >>> p.add_line(osc, dac)
304
+ >>> export_svg(p, '/tmp/test.svg')
305
+ """
306
+ output_path = Path(output_path)
307
+
308
+ # Calculate viewBox
309
+ vx, vy, vw, vh = _calculate_viewbox(patcher)
310
+
311
+ # Start SVG
312
+ svg_parts = [
313
+ '<?xml version="1.0" encoding="UTF-8"?>',
314
+ f'<svg xmlns="http://www.w3.org/2000/svg" '
315
+ f'viewBox="{vx} {vy} {vw} {vh}" '
316
+ f'width="{vw}" height="{vh}">',
317
+ "",
318
+ "<defs>",
319
+ " <style>",
320
+ " text { user-select: none; }",
321
+ " </style>",
322
+ "</defs>",
323
+ "",
324
+ ]
325
+
326
+ # Add title if provided
327
+ if title:
328
+ title_y = vy + 20
329
+ svg_parts.extend(
330
+ [
331
+ f'<text x="{vx + vw / 2}" y="{title_y}" '
332
+ f'font-family="{TEXT_FONT_FAMILY}" font-size="16" font-weight="bold" '
333
+ f'fill="{TEXT_COLOR}" text-anchor="middle">{_escape_text(title)}</text>',
334
+ "",
335
+ ]
336
+ )
337
+
338
+ # Render patchlines first (so they appear behind boxes)
339
+ svg_parts.append("<!-- Patchlines -->")
340
+ for line in patcher._lines:
341
+ line_svg = _render_patchline(line, patcher)
342
+ if line_svg:
343
+ svg_parts.append(line_svg)
344
+ svg_parts.append("")
345
+
346
+ # Render boxes
347
+ svg_parts.append("<!-- Boxes -->")
348
+ for box in patcher._boxes:
349
+ box_svg = _render_box(box, show_ports=show_ports)
350
+ if box_svg:
351
+ svg_parts.append(box_svg)
352
+ svg_parts.append("")
353
+
354
+ # Close SVG
355
+ svg_parts.append("</svg>")
356
+
357
+ # Write to file
358
+ svg_content = "\n".join(svg_parts)
359
+ output_path.write_text(svg_content, encoding="utf-8")
360
+
361
+
362
+ def export_svg_string(
363
+ patcher: Patcher,
364
+ show_ports: bool = True,
365
+ title: Optional[str] = None,
366
+ ) -> str:
367
+ """Export a patcher to SVG format as a string.
368
+
369
+ Args:
370
+ patcher: The Patcher object to export.
371
+ show_ports: Whether to show inlet/outlet ports on boxes.
372
+ title: Optional title to display at top of SVG.
373
+
374
+ Returns:
375
+ SVG content as a string.
376
+
377
+ Example:
378
+ >>> p = Patcher('test.maxpat')
379
+ >>> svg_str = export_svg_string(p)
380
+ """
381
+ import tempfile
382
+
383
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".svg", delete=False) as f:
384
+ temp_path = Path(f.name)
385
+
386
+ try:
387
+ export_svg(patcher, temp_path, show_ports=show_ports, title=title)
388
+ return temp_path.read_text(encoding="utf-8")
389
+ finally:
390
+ temp_path.unlink()
391
+
392
+
393
+ __all__ = ["export_svg", "export_svg_string"]
@@ -0,0 +1,26 @@
1
+ """Layout management for py2max patches.
2
+
3
+ This subpackage provides various layout managers for automatic positioning of
4
+ Max objects in patches:
5
+
6
+ - LayoutManager: Basic horizontal layout (deprecated)
7
+ - GridLayoutManager: Grid-based layout with clustering
8
+ - FlowLayoutManager: Signal flow-based hierarchical layout
9
+ - MatrixLayoutManager: Matrix/columnar layout for signal chains
10
+ - HorizontalLayoutManager: Legacy alias for GridLayoutManager (horizontal)
11
+ - VerticalLayoutManager: Legacy alias for GridLayoutManager (vertical)
12
+ """
13
+
14
+ from .base import LayoutManager
15
+ from .flow import FlowLayoutManager
16
+ from .grid import GridLayoutManager, HorizontalLayoutManager, VerticalLayoutManager
17
+ from .matrix import MatrixLayoutManager
18
+
19
+ __all__ = [
20
+ "LayoutManager",
21
+ "GridLayoutManager",
22
+ "HorizontalLayoutManager",
23
+ "VerticalLayoutManager",
24
+ "FlowLayoutManager",
25
+ "MatrixLayoutManager",
26
+ ]