rocket-welder-sdk 1.1.44__py3-none-any.whl → 1.1.45__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,575 @@
1
+ """
2
+ VectorGraphics Protocol V2 Encoder.
3
+
4
+ Matches C# VectorGraphicsEncoderV2 from BlazorBlaze.VectorGraphics.Protocol.
5
+
6
+ Wire format:
7
+ Message:
8
+ [GlobalFrameId: 8 bytes LE]
9
+ [LayerCount: 1 byte]
10
+ For each layer:
11
+ [LayerBlock...]
12
+ [EndMarker: 0xFF 0xFF]
13
+
14
+ LayerBlock:
15
+ [LayerId: 1 byte]
16
+ [FrameType: 1 byte] // 0x00=Master, 0x01=Remain, 0x02=Clear
17
+ If FrameType == Master:
18
+ [OpCount: varint]
19
+ [Operations...]
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import struct
25
+ from typing import Sequence, Tuple
26
+
27
+ from rocket_welder_sdk.varint import write_varint, write_zigzag
28
+
29
+ from .protocol import END_MARKER_BYTE1, END_MARKER_BYTE2, FrameType, OpType, PropertyId
30
+ from .rgb_color import RgbColor # noqa: TC001 - used at runtime in method bodies
31
+
32
+
33
+ class VectorGraphicsEncoder:
34
+ """
35
+ Protocol V2 encoder for stateful canvas API with multi-layer support.
36
+
37
+ Provides static methods for encoding VectorGraphics protocol messages.
38
+ All methods write to a bytearray at a given offset and return bytes written.
39
+ """
40
+
41
+ @staticmethod
42
+ def write_message_header(
43
+ buffer: bytearray, offset: int, frame_id: int, layer_count: int
44
+ ) -> int:
45
+ """
46
+ Write message header with frame ID and layer count.
47
+
48
+ Args:
49
+ buffer: Destination buffer
50
+ offset: Starting offset
51
+ frame_id: Frame identifier (64-bit unsigned)
52
+ layer_count: Number of layers (0-255)
53
+
54
+ Returns:
55
+ Number of bytes written (9)
56
+ """
57
+ struct.pack_into("<Q", buffer, offset, frame_id)
58
+ buffer[offset + 8] = layer_count
59
+ return 9
60
+
61
+ @staticmethod
62
+ def write_end_marker(buffer: bytearray, offset: int) -> int:
63
+ """
64
+ Write end marker (0xFF 0xFF).
65
+
66
+ Args:
67
+ buffer: Destination buffer
68
+ offset: Starting offset
69
+
70
+ Returns:
71
+ Number of bytes written (2)
72
+ """
73
+ buffer[offset] = END_MARKER_BYTE1
74
+ buffer[offset + 1] = END_MARKER_BYTE2
75
+ return 2
76
+
77
+ # ============== Layer Block ==============
78
+
79
+ @staticmethod
80
+ def write_layer_master(buffer: bytearray, offset: int, layer_id: int, op_count: int) -> int:
81
+ """
82
+ Write layer block header for Master frame type.
83
+
84
+ Args:
85
+ buffer: Destination buffer
86
+ offset: Starting offset
87
+ layer_id: Layer ID (0-255)
88
+ op_count: Number of operations
89
+
90
+ Returns:
91
+ Number of bytes written
92
+ """
93
+ buffer[offset] = layer_id
94
+ buffer[offset + 1] = FrameType.MASTER
95
+ return 2 + write_varint(buffer, offset + 2, op_count)
96
+
97
+ @staticmethod
98
+ def write_layer_remain(buffer: bytearray, offset: int, layer_id: int) -> int:
99
+ """
100
+ Write layer block for Remain frame type (keep previous content).
101
+
102
+ Args:
103
+ buffer: Destination buffer
104
+ offset: Starting offset
105
+ layer_id: Layer ID (0-255)
106
+
107
+ Returns:
108
+ Number of bytes written (2)
109
+ """
110
+ buffer[offset] = layer_id
111
+ buffer[offset + 1] = FrameType.REMAIN
112
+ return 2
113
+
114
+ @staticmethod
115
+ def write_layer_clear(buffer: bytearray, offset: int, layer_id: int) -> int:
116
+ """
117
+ Write layer block for Clear frame type (clear to transparent).
118
+
119
+ Args:
120
+ buffer: Destination buffer
121
+ offset: Starting offset
122
+ layer_id: Layer ID (0-255)
123
+
124
+ Returns:
125
+ Number of bytes written (2)
126
+ """
127
+ buffer[offset] = layer_id
128
+ buffer[offset + 1] = FrameType.CLEAR
129
+ return 2
130
+
131
+ # ============== Context Operations - Styling ==============
132
+
133
+ @staticmethod
134
+ def write_set_stroke(buffer: bytearray, offset: int, color: RgbColor) -> int:
135
+ """
136
+ Write SetContext for stroke color.
137
+
138
+ Args:
139
+ buffer: Destination buffer
140
+ offset: Starting offset
141
+ color: Stroke color
142
+
143
+ Returns:
144
+ Number of bytes written (7)
145
+ """
146
+ buffer[offset] = OpType.SET_CONTEXT
147
+ buffer[offset + 1] = 1 # 1 field
148
+ buffer[offset + 2] = PropertyId.STROKE
149
+ buffer[offset + 3] = color.r
150
+ buffer[offset + 4] = color.g
151
+ buffer[offset + 5] = color.b
152
+ buffer[offset + 6] = color.a
153
+ return 7
154
+
155
+ @staticmethod
156
+ def write_set_fill(buffer: bytearray, offset: int, color: RgbColor) -> int:
157
+ """
158
+ Write SetContext for fill color.
159
+
160
+ Args:
161
+ buffer: Destination buffer
162
+ offset: Starting offset
163
+ color: Fill color
164
+
165
+ Returns:
166
+ Number of bytes written (7)
167
+ """
168
+ buffer[offset] = OpType.SET_CONTEXT
169
+ buffer[offset + 1] = 1
170
+ buffer[offset + 2] = PropertyId.FILL
171
+ buffer[offset + 3] = color.r
172
+ buffer[offset + 4] = color.g
173
+ buffer[offset + 5] = color.b
174
+ buffer[offset + 6] = color.a
175
+ return 7
176
+
177
+ @staticmethod
178
+ def write_set_thickness(buffer: bytearray, offset: int, thickness: int) -> int:
179
+ """
180
+ Write SetContext for stroke thickness.
181
+
182
+ Args:
183
+ buffer: Destination buffer
184
+ offset: Starting offset
185
+ thickness: Stroke thickness in pixels
186
+
187
+ Returns:
188
+ Number of bytes written
189
+ """
190
+ buffer[offset] = OpType.SET_CONTEXT
191
+ buffer[offset + 1] = 1
192
+ buffer[offset + 2] = PropertyId.THICKNESS
193
+ return 3 + write_varint(buffer, offset + 3, thickness)
194
+
195
+ @staticmethod
196
+ def write_set_font_size(buffer: bytearray, offset: int, size: int) -> int:
197
+ """
198
+ Write SetContext for font size.
199
+
200
+ Args:
201
+ buffer: Destination buffer
202
+ offset: Starting offset
203
+ size: Font size in pixels
204
+
205
+ Returns:
206
+ Number of bytes written
207
+ """
208
+ buffer[offset] = OpType.SET_CONTEXT
209
+ buffer[offset + 1] = 1
210
+ buffer[offset + 2] = PropertyId.FONT_SIZE
211
+ return 3 + write_varint(buffer, offset + 3, size)
212
+
213
+ @staticmethod
214
+ def write_set_font_color(buffer: bytearray, offset: int, color: RgbColor) -> int:
215
+ """
216
+ Write SetContext for font color.
217
+
218
+ Args:
219
+ buffer: Destination buffer
220
+ offset: Starting offset
221
+ color: Font color
222
+
223
+ Returns:
224
+ Number of bytes written (7)
225
+ """
226
+ buffer[offset] = OpType.SET_CONTEXT
227
+ buffer[offset + 1] = 1
228
+ buffer[offset + 2] = PropertyId.FONT_COLOR
229
+ buffer[offset + 3] = color.r
230
+ buffer[offset + 4] = color.g
231
+ buffer[offset + 5] = color.b
232
+ buffer[offset + 6] = color.a
233
+ return 7
234
+
235
+ # ============== Context Operations - Transforms ==============
236
+
237
+ @staticmethod
238
+ def write_set_offset(buffer: bytearray, offset: int, x: float, y: float) -> int:
239
+ """
240
+ Write SetContext for translation offset.
241
+
242
+ Args:
243
+ buffer: Destination buffer
244
+ offset: Starting offset
245
+ x: X translation
246
+ y: Y translation
247
+
248
+ Returns:
249
+ Number of bytes written
250
+ """
251
+ buffer[offset] = OpType.SET_CONTEXT
252
+ buffer[offset + 1] = 1
253
+ buffer[offset + 2] = PropertyId.OFFSET
254
+ pos = offset + 3
255
+ pos += write_zigzag(buffer, pos, int(x))
256
+ pos += write_zigzag(buffer, pos, int(y))
257
+ return pos - offset
258
+
259
+ @staticmethod
260
+ def write_set_rotation(buffer: bytearray, offset: int, degrees: float) -> int:
261
+ """
262
+ Write SetContext for rotation in degrees.
263
+
264
+ Args:
265
+ buffer: Destination buffer
266
+ offset: Starting offset
267
+ degrees: Rotation angle in degrees
268
+
269
+ Returns:
270
+ Number of bytes written (7)
271
+ """
272
+ buffer[offset] = OpType.SET_CONTEXT
273
+ buffer[offset + 1] = 1
274
+ buffer[offset + 2] = PropertyId.ROTATION
275
+ struct.pack_into("<f", buffer, offset + 3, degrees)
276
+ return 7
277
+
278
+ @staticmethod
279
+ def write_set_scale(buffer: bytearray, offset: int, scale_x: float, scale_y: float) -> int:
280
+ """
281
+ Write SetContext for scale.
282
+
283
+ Args:
284
+ buffer: Destination buffer
285
+ offset: Starting offset
286
+ scale_x: X scale factor
287
+ scale_y: Y scale factor
288
+
289
+ Returns:
290
+ Number of bytes written (11)
291
+ """
292
+ buffer[offset] = OpType.SET_CONTEXT
293
+ buffer[offset + 1] = 1
294
+ buffer[offset + 2] = PropertyId.SCALE
295
+ struct.pack_into("<ff", buffer, offset + 3, scale_x, scale_y)
296
+ return 11
297
+
298
+ @staticmethod
299
+ def write_set_skew(buffer: bytearray, offset: int, skew_x: float, skew_y: float) -> int:
300
+ """
301
+ Write SetContext for skew.
302
+
303
+ Args:
304
+ buffer: Destination buffer
305
+ offset: Starting offset
306
+ skew_x: X skew factor
307
+ skew_y: Y skew factor
308
+
309
+ Returns:
310
+ Number of bytes written (11)
311
+ """
312
+ buffer[offset] = OpType.SET_CONTEXT
313
+ buffer[offset + 1] = 1
314
+ buffer[offset + 2] = PropertyId.SKEW
315
+ struct.pack_into("<ff", buffer, offset + 3, skew_x, skew_y)
316
+ return 11
317
+
318
+ @staticmethod
319
+ def write_set_matrix(
320
+ buffer: bytearray,
321
+ offset: int,
322
+ scale_x: float,
323
+ skew_x: float,
324
+ trans_x: float,
325
+ skew_y: float,
326
+ scale_y: float,
327
+ trans_y: float,
328
+ ) -> int:
329
+ """
330
+ Write SetContext for full transformation matrix (6 floats).
331
+
332
+ Matrix layout matches SKMatrix:
333
+ | ScaleX SkewX TransX |
334
+ | SkewY ScaleY TransY |
335
+ | Persp0 Persp1 Persp2 | (not sent, assumed identity)
336
+
337
+ Args:
338
+ buffer: Destination buffer
339
+ offset: Starting offset
340
+ scale_x, skew_x, trans_x: First row
341
+ skew_y, scale_y, trans_y: Second row
342
+
343
+ Returns:
344
+ Number of bytes written (27)
345
+ """
346
+ buffer[offset] = OpType.SET_CONTEXT
347
+ buffer[offset + 1] = 1
348
+ buffer[offset + 2] = PropertyId.MATRIX
349
+ struct.pack_into(
350
+ "<ffffff", buffer, offset + 3, scale_x, skew_x, trans_x, skew_y, scale_y, trans_y
351
+ )
352
+ return 27
353
+
354
+ # ============== Context Stack ==============
355
+
356
+ @staticmethod
357
+ def write_save_context(buffer: bytearray, offset: int) -> int:
358
+ """
359
+ Write SaveContext operation.
360
+
361
+ Args:
362
+ buffer: Destination buffer
363
+ offset: Starting offset
364
+
365
+ Returns:
366
+ Number of bytes written (1)
367
+ """
368
+ buffer[offset] = OpType.SAVE_CONTEXT
369
+ return 1
370
+
371
+ @staticmethod
372
+ def write_restore_context(buffer: bytearray, offset: int) -> int:
373
+ """
374
+ Write RestoreContext operation.
375
+
376
+ Args:
377
+ buffer: Destination buffer
378
+ offset: Starting offset
379
+
380
+ Returns:
381
+ Number of bytes written (1)
382
+ """
383
+ buffer[offset] = OpType.RESTORE_CONTEXT
384
+ return 1
385
+
386
+ @staticmethod
387
+ def write_reset_context(buffer: bytearray, offset: int) -> int:
388
+ """
389
+ Write ResetContext operation.
390
+
391
+ Args:
392
+ buffer: Destination buffer
393
+ offset: Starting offset
394
+
395
+ Returns:
396
+ Number of bytes written (1)
397
+ """
398
+ buffer[offset] = OpType.RESET_CONTEXT
399
+ return 1
400
+
401
+ # ============== Draw Operations ==============
402
+
403
+ @staticmethod
404
+ def write_draw_polygon(
405
+ buffer: bytearray, offset: int, points: Sequence[Tuple[float, float]]
406
+ ) -> int:
407
+ """
408
+ Write DrawPolygon operation with delta-encoded points.
409
+
410
+ Args:
411
+ buffer: Destination buffer
412
+ offset: Starting offset
413
+ points: Sequence of (x, y) tuples
414
+
415
+ Returns:
416
+ Number of bytes written
417
+ """
418
+ pos = offset
419
+ buffer[pos] = OpType.DRAW_POLYGON
420
+ pos += 1
421
+ pos += write_varint(buffer, pos, len(points))
422
+
423
+ if len(points) > 0:
424
+ # First point - absolute
425
+ first_x = int(points[0][0])
426
+ first_y = int(points[0][1])
427
+ pos += write_zigzag(buffer, pos, first_x)
428
+ pos += write_zigzag(buffer, pos, first_y)
429
+
430
+ # Subsequent points - delta encoded
431
+ last_x = first_x
432
+ last_y = first_y
433
+ for i in range(1, len(points)):
434
+ x = int(points[i][0])
435
+ y = int(points[i][1])
436
+ pos += write_zigzag(buffer, pos, x - last_x)
437
+ pos += write_zigzag(buffer, pos, y - last_y)
438
+ last_x = x
439
+ last_y = y
440
+
441
+ return pos - offset
442
+
443
+ @staticmethod
444
+ def write_draw_text(buffer: bytearray, offset: int, text: str, x: int, y: int) -> int:
445
+ """
446
+ Write DrawText operation.
447
+
448
+ Args:
449
+ buffer: Destination buffer
450
+ offset: Starting offset
451
+ text: Text to draw
452
+ x: X position
453
+ y: Y position
454
+
455
+ Returns:
456
+ Number of bytes written
457
+ """
458
+ pos = offset
459
+ buffer[pos] = OpType.DRAW_TEXT
460
+ pos += 1
461
+ pos += write_zigzag(buffer, pos, x)
462
+ pos += write_zigzag(buffer, pos, y)
463
+
464
+ text_bytes = text.encode("utf-8")
465
+ pos += write_varint(buffer, pos, len(text_bytes))
466
+ buffer[pos : pos + len(text_bytes)] = text_bytes
467
+ pos += len(text_bytes)
468
+
469
+ return pos - offset
470
+
471
+ @staticmethod
472
+ def write_draw_circle(
473
+ buffer: bytearray, offset: int, center_x: int, center_y: int, radius: int
474
+ ) -> int:
475
+ """
476
+ Write DrawCircle operation.
477
+
478
+ Args:
479
+ buffer: Destination buffer
480
+ offset: Starting offset
481
+ center_x: Center X coordinate
482
+ center_y: Center Y coordinate
483
+ radius: Circle radius
484
+
485
+ Returns:
486
+ Number of bytes written
487
+ """
488
+ pos = offset
489
+ buffer[pos] = OpType.DRAW_CIRCLE
490
+ pos += 1
491
+ pos += write_zigzag(buffer, pos, center_x)
492
+ pos += write_zigzag(buffer, pos, center_y)
493
+ pos += write_varint(buffer, pos, radius)
494
+ return pos - offset
495
+
496
+ @staticmethod
497
+ def write_draw_rect(
498
+ buffer: bytearray, offset: int, x: int, y: int, width: int, height: int
499
+ ) -> int:
500
+ """
501
+ Write DrawRect operation.
502
+
503
+ Args:
504
+ buffer: Destination buffer
505
+ offset: Starting offset
506
+ x: X position
507
+ y: Y position
508
+ width: Rectangle width
509
+ height: Rectangle height
510
+
511
+ Returns:
512
+ Number of bytes written
513
+ """
514
+ pos = offset
515
+ buffer[pos] = OpType.DRAW_RECT
516
+ pos += 1
517
+ pos += write_zigzag(buffer, pos, x)
518
+ pos += write_zigzag(buffer, pos, y)
519
+ pos += write_varint(buffer, pos, width)
520
+ pos += write_varint(buffer, pos, height)
521
+ return pos - offset
522
+
523
+ @staticmethod
524
+ def write_draw_line(buffer: bytearray, offset: int, x1: int, y1: int, x2: int, y2: int) -> int:
525
+ """
526
+ Write DrawLine operation.
527
+
528
+ Args:
529
+ buffer: Destination buffer
530
+ offset: Starting offset
531
+ x1, y1: Start point
532
+ x2, y2: End point
533
+
534
+ Returns:
535
+ Number of bytes written
536
+ """
537
+ pos = offset
538
+ buffer[pos] = OpType.DRAW_LINE
539
+ pos += 1
540
+ pos += write_zigzag(buffer, pos, x1)
541
+ pos += write_zigzag(buffer, pos, y1)
542
+ pos += write_zigzag(buffer, pos, x2)
543
+ pos += write_zigzag(buffer, pos, y2)
544
+ return pos - offset
545
+
546
+ @staticmethod
547
+ def write_draw_jpeg(
548
+ buffer: bytearray, offset: int, jpeg_data: bytes, x: int, y: int, width: int, height: int
549
+ ) -> int:
550
+ """
551
+ Write DrawJpeg operation with raw JPEG data.
552
+
553
+ Args:
554
+ buffer: Destination buffer
555
+ offset: Starting offset
556
+ jpeg_data: Raw JPEG bytes
557
+ x: X position
558
+ y: Y position
559
+ width: Display width
560
+ height: Display height
561
+
562
+ Returns:
563
+ Number of bytes written
564
+ """
565
+ pos = offset
566
+ buffer[pos] = OpType.DRAW_JPEG
567
+ pos += 1
568
+ pos += write_zigzag(buffer, pos, x)
569
+ pos += write_zigzag(buffer, pos, y)
570
+ pos += write_varint(buffer, pos, width)
571
+ pos += write_varint(buffer, pos, height)
572
+ pos += write_varint(buffer, pos, len(jpeg_data))
573
+ buffer[pos : pos + len(jpeg_data)] = jpeg_data
574
+ pos += len(jpeg_data)
575
+ return pos - offset
@@ -317,3 +317,88 @@ class SegmentationConnectionString:
317
317
 
318
318
  def __str__(self) -> str:
319
319
  return self.value
320
+
321
+
322
+ @dataclass(frozen=True)
323
+ class GraphicsConnectionString:
324
+ """
325
+ Strongly-typed connection string for Stage (graphics) output.
326
+
327
+ Supported protocols:
328
+ - file:///path/to/file.bin - File output (absolute path)
329
+ - socket:///tmp/socket.sock - Unix domain socket
330
+ """
331
+
332
+ value: str
333
+ protocol: TransportProtocol
334
+ address: str
335
+ parameters: Dict[str, str] = field(default_factory=dict)
336
+
337
+ @classmethod
338
+ def default(cls) -> GraphicsConnectionString:
339
+ """Default connection string for Stage."""
340
+ return cls.parse("socket:///tmp/rocket-welder-stage.sock")
341
+
342
+ @classmethod
343
+ def from_environment(
344
+ cls, variable_name: str = "STAGE_CONNECTION_STRING"
345
+ ) -> GraphicsConnectionString:
346
+ """Create from environment variable or use default."""
347
+ value = os.environ.get(variable_name)
348
+ return cls.parse(value) if value else cls.default()
349
+
350
+ @classmethod
351
+ def parse(cls, s: str) -> GraphicsConnectionString:
352
+ """Parse a connection string."""
353
+ result = cls.try_parse(s)
354
+ if result is None:
355
+ raise ValueError(f"Invalid Graphics connection string: {s}")
356
+ return result
357
+
358
+ @classmethod
359
+ def try_parse(cls, s: str) -> Optional[GraphicsConnectionString]:
360
+ """Try to parse a connection string."""
361
+ if not s or not s.strip():
362
+ return None
363
+
364
+ s = s.strip()
365
+ parameters: Dict[str, str] = {}
366
+
367
+ # Extract query parameters
368
+ endpoint_part = s
369
+ if "?" in s:
370
+ endpoint_part, query = s.split("?", 1)
371
+ for key, values in parse_qs(query).items():
372
+ parameters[key.lower()] = values[0] if values else ""
373
+
374
+ # Parse protocol and address
375
+ scheme_end = endpoint_part.find("://")
376
+ if scheme_end <= 0:
377
+ return None
378
+
379
+ schema_str = endpoint_part[:scheme_end]
380
+ path_part = endpoint_part[scheme_end + 3 :] # skip "://"
381
+
382
+ protocol = TransportProtocol.try_parse(schema_str)
383
+ if protocol is None:
384
+ return None
385
+
386
+ # Build address based on protocol type
387
+ if protocol.is_file:
388
+ # file:///absolute/path -> /absolute/path
389
+ address = path_part if path_part.startswith("/") else "/" + path_part
390
+ elif protocol.is_socket:
391
+ # socket:///tmp/sock -> /tmp/sock
392
+ address = path_part if path_part.startswith("/") else "/" + path_part
393
+ else:
394
+ return None
395
+
396
+ return cls(
397
+ value=s,
398
+ protocol=protocol,
399
+ address=address,
400
+ parameters=parameters,
401
+ )
402
+
403
+ def __str__(self) -> str:
404
+ return self.value