drawsvg-ui 0.4.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.
export_drawsvg.py ADDED
@@ -0,0 +1,1700 @@
1
+ import json
2
+
3
+ import math
4
+
5
+ from collections.abc import Iterable
6
+
7
+ from PySide6 import QtCore, QtGui, QtWidgets
8
+
9
+ from constants import SHAPES, PEN_STYLE_DASH_ARRAYS, DEFAULT_FONT_FAMILY
10
+
11
+ from items import (
12
+
13
+ BlockArrowItem,
14
+
15
+ CurvyBracketItem,
16
+
17
+ DiamondItem,
18
+
19
+ FolderTreeItem,
20
+
21
+ LineItem,
22
+
23
+ RectItem,
24
+
25
+ ShapeLabelMixin,
26
+
27
+ SplitRoundedRectItem,
28
+
29
+ )
30
+
31
+ def _format_item_attributes(
32
+
33
+ item: QtWidgets.QGraphicsItem,
34
+
35
+ *,
36
+
37
+ include_fill: bool = True,
38
+
39
+ extra_attrs: Iterable[str] | None = None,
40
+
41
+ ) -> str:
42
+
43
+ """Return a drawsvg-compatible attribute string for ``item``.
44
+
45
+ Parameters
46
+
47
+ ----------
48
+
49
+ item:
50
+
51
+ The graphics item whose pen/brush information should be exported.
52
+
53
+ include_fill:
54
+
55
+ Whether fill information should be included (set to ``False`` for
56
+
57
+ stroke-only shapes).
58
+
59
+ extra_attrs:
60
+
61
+ Optional iterable of additional attributes that should be appended to
62
+
63
+ the generated string (e.g., rounded corner radii).
64
+
65
+ """
66
+
67
+ attrs: list[str] = []
68
+
69
+ if include_fill:
70
+
71
+ brush_getter = getattr(item, "brush", None)
72
+
73
+ if callable(brush_getter):
74
+
75
+ brush = brush_getter()
76
+
77
+ if brush.style() == QtCore.Qt.BrushStyle.NoBrush:
78
+
79
+ attrs.append("fill='none'")
80
+
81
+ else:
82
+
83
+ color = brush.color()
84
+
85
+ attrs.append(f"fill='{color.name()}'")
86
+
87
+ attrs.append(f"fill_opacity={color.alphaF():.2f}")
88
+
89
+ else:
90
+
91
+ attrs.append("fill='none'")
92
+
93
+ pen_getter = getattr(item, "pen", None)
94
+
95
+ if callable(pen_getter):
96
+
97
+ pen = pen_getter()
98
+
99
+ attrs.append(f"stroke='{pen.color().name()}'")
100
+
101
+ attrs.append(f"stroke_width={pen.widthF():.2f}")
102
+
103
+ dash_str = _pen_dash_array_string(pen)
104
+
105
+ if dash_str:
106
+
107
+ attrs.append(f"stroke_dasharray='{dash_str}'")
108
+
109
+ if extra_attrs:
110
+
111
+ attrs.extend(extra_attrs)
112
+
113
+ return ", ".join(attrs)
114
+
115
+ def _pen_dash_array_string(pen: QtGui.QPen) -> str | None:
116
+
117
+ dash_array = PEN_STYLE_DASH_ARRAYS.get(pen.style())
118
+
119
+ if dash_array:
120
+
121
+ return " ".join(f"{value:.2f}" for value in dash_array)
122
+
123
+ pattern = pen.dashPattern()
124
+
125
+ if pattern:
126
+
127
+ return " ".join(f"{value:.2f}" for value in pattern)
128
+
129
+ return None
130
+
131
+ def _escape_draw_text(value: str) -> str:
132
+
133
+ return (
134
+
135
+ value.replace('\\', '\\\\')
136
+
137
+ .replace("'", "\'")
138
+
139
+ .replace('\n', '\\n')
140
+
141
+ .replace('\r', '\\r')
142
+
143
+ )
144
+
145
+ def _export_shape_label(
146
+
147
+ item: ShapeLabelMixin,
148
+
149
+ lines: list[str],
150
+
151
+ *,
152
+
153
+ shape_id: str | None,
154
+
155
+ angle: float,
156
+
157
+ base_pos: tuple[float, float] | None = None,
158
+
159
+ base_size: tuple[float, float] | None = None,
160
+
161
+ var_name: str = "shape_label",
162
+
163
+ label_kind: str | None = None,
164
+
165
+ ) -> None:
166
+
167
+ if not shape_id:
168
+
169
+ return
170
+
171
+ if not getattr(item, "has_label", lambda: False)():
172
+
173
+ return
174
+
175
+ text_value = getattr(item, "label_text", lambda: "")()
176
+
177
+ if not text_value:
178
+
179
+ return
180
+
181
+ label_item = getattr(item, "label_item", lambda: None)()
182
+
183
+ if label_item is None:
184
+
185
+ return
186
+
187
+ raw_lines = text_value.splitlines()
188
+
189
+ if text_value.endswith(("\r", "\n")):
190
+
191
+ raw_lines.append("")
192
+
193
+ if not raw_lines:
194
+
195
+ raw_lines = [text_value]
196
+
197
+ font = label_item.font()
198
+
199
+ fm = QtGui.QFontMetricsF(font)
200
+
201
+ pixel_size = float(font.pixelSize())
202
+
203
+ if pixel_size <= 0.0:
204
+
205
+ point_size = font.pointSizeF()
206
+
207
+ if point_size > 0.0:
208
+
209
+ screen = QtGui.QGuiApplication.primaryScreen()
210
+
211
+ dpi = screen.logicalDotsPerInch() if screen else 96.0
212
+
213
+ pixel_size = point_size * dpi / 72.0
214
+
215
+ if pixel_size <= 0.0:
216
+
217
+ pixel_size = fm.height()
218
+
219
+ scale = label_item.scale() or 1.0
220
+
221
+ size = pixel_size * scale
222
+
223
+ line_px = fm.lineSpacing() * scale
224
+
225
+ line_ratio = line_px / size if size > 0.0 else 1.0
226
+
227
+ bounds = item.boundingRect()
228
+
229
+ if base_pos is None:
230
+
231
+ base_pos = (item.pos().x() + bounds.x(), item.pos().y() + bounds.y())
232
+
233
+ if base_size is None:
234
+
235
+ base_size = (bounds.width(), bounds.height())
236
+
237
+ bx, by = base_pos
238
+
239
+ bw, bh = base_size
240
+
241
+ label_pos = label_item.pos()
242
+
243
+ br = label_item.boundingRect()
244
+
245
+ text_left = bx + label_pos.x() + br.left()
246
+
247
+ text_top = by + label_pos.y() + br.top()
248
+
249
+ h_align, v_align = getattr(item, "label_alignment", lambda: ("center", "middle"))()
250
+
251
+ anchor_map = {"left": "start", "center": "middle", "right": "end"}
252
+
253
+ text_anchor = anchor_map.get(h_align, "middle")
254
+
255
+ baseline_offset = 0.0
256
+
257
+ doc = label_item.document()
258
+
259
+ if doc is not None:
260
+
261
+ block = doc.begin()
262
+
263
+ if block.isValid():
264
+
265
+ layout = block.layout()
266
+
267
+ if layout is not None and layout.lineCount() > 0:
268
+
269
+ first_line = layout.lineAt(0)
270
+
271
+ baseline_offset = layout.position().y() + first_line.y() + first_line.ascent()
272
+
273
+ anchor_x = (
274
+
275
+ text_left if h_align == "left"
276
+
277
+ else (text_left + br.width() if h_align == "right"
278
+
279
+ else text_left + br.width() / 2.0)
280
+
281
+ )
282
+
283
+ first_baseline_y = text_top + baseline_offset
284
+
285
+ color = label_item.defaultTextColor()
286
+
287
+ attrs = [
288
+
289
+ f"fill='{color.name()}'",
290
+
291
+ f"font_family='{font.family()}'",
292
+
293
+ f"text_anchor='{text_anchor}'",
294
+
295
+ "dominant_baseline='alphabetic'",
296
+
297
+ f"line_height={line_ratio:.6f}",
298
+
299
+ "xml__space='preserve'",
300
+
301
+ "data_shape_label='true'",
302
+
303
+ f"data_label_id='{shape_id}'",
304
+
305
+ f"data_label_h='{h_align}'",
306
+
307
+ f"data_label_v='{v_align}'",
308
+
309
+ f"data_font_px={pixel_size:.4f}",
310
+
311
+ ]
312
+ if item.label_has_custom_color():
313
+
314
+ attrs.append("data_label_color_override='true'")
315
+
316
+ if label_kind:
317
+
318
+ attrs.append(f"data_label_kind='{label_kind}'")
319
+
320
+ if label_kind == "rect":
321
+
322
+ attrs.append("data_rect_label='true'")
323
+
324
+ if color.alphaF() < 1.0:
325
+
326
+ attrs.append(f"fill_opacity={color.alphaF():.2f}")
327
+
328
+ attr_str = ", ".join(attrs)
329
+
330
+ transform_suffix = ""
331
+
332
+ if abs(angle) > 1e-6:
333
+
334
+ cx = bx + bw / 2.0
335
+
336
+ cy = by + bh / 2.0
337
+
338
+ transform_suffix = f", transform='rotate({angle:.2f} {cx:.2f} {cy:.2f})'"
339
+
340
+ json_lines = [line if line else "\u00A0" for line in raw_lines]
341
+
342
+ if len(json_lines) == 1:
343
+
344
+ text_literal = json.dumps(json_lines[0], ensure_ascii=False)
345
+
346
+ else:
347
+
348
+ text_literal = json.dumps(json_lines, ensure_ascii=False)
349
+
350
+ lines.append(f" # Multiline label for {shape_id}")
351
+
352
+ lines.append(
353
+
354
+ f" _{var_name} = draw.Text({text_literal}, {size:.2f}, {anchor_x:.2f}, {first_baseline_y:.2f}, {attr_str}{transform_suffix})"
355
+
356
+ )
357
+
358
+ lines.append(f" d.append(_{var_name})")
359
+
360
+ def _painter_path_to_svg(path: QtGui.QPainterPath) -> str:
361
+
362
+ """Return a compact SVG path string for ``path``.
363
+
364
+ The conversion flattens the painter path into polygons and emits
365
+
366
+ ``M/L`` commands for each subpath. Rounded corners are approximated by
367
+
368
+ straight segments using Qt's internal flattening tolerance which is
369
+
370
+ sufficient for the exported preview rendering.
371
+
372
+ """
373
+
374
+ segments: list[str] = []
375
+
376
+ for poly in path.toSubpathPolygons():
377
+
378
+ if not poly:
379
+
380
+ continue
381
+
382
+ commands: list[str] = []
383
+
384
+ points = list(poly)
385
+
386
+ closed = False
387
+
388
+ if len(points) >= 2:
389
+
390
+ first = points[0]
391
+
392
+ last = points[-1]
393
+
394
+ if math.hypot(first.x() - last.x(), first.y() - last.y()) <= 1e-4:
395
+
396
+ closed = True
397
+
398
+ points = points[:-1]
399
+
400
+ start = points[0]
401
+
402
+ commands.append(f"M {start.x():.2f} {start.y():.2f}")
403
+
404
+ for point in points[1:]:
405
+
406
+ commands.append(f"L {point.x():.2f} {point.y():.2f}")
407
+
408
+ if closed:
409
+
410
+ commands.append("Z")
411
+
412
+ segments.append(" ".join(commands))
413
+
414
+ return " ".join(segments)
415
+
416
+ def _arrowhead_polygon(
417
+
418
+ start: QtCore.QPointF,
419
+
420
+ end: QtCore.QPointF,
421
+
422
+ length: float,
423
+
424
+ width: float,
425
+
426
+ ) -> list[QtCore.QPointF]:
427
+
428
+ """Return a list of points describing an arrowhead polygon."""
429
+
430
+ line = QtCore.QLineF(start, end)
431
+
432
+ tip = QtCore.QPointF(end)
433
+
434
+ distance = line.length()
435
+
436
+ if distance <= 1e-6:
437
+
438
+ return [tip, tip, tip]
439
+
440
+ arrow_length = max(float(length), 0.0)
441
+
442
+ arrow_width = max(float(width), 0.0)
443
+
444
+ if arrow_length <= 1e-6 or arrow_width <= 1e-6:
445
+
446
+ return [tip, tip, tip]
447
+
448
+ unit_x = (end.x() - start.x()) / distance
449
+
450
+ unit_y = (end.y() - start.y()) / distance
451
+
452
+ perp_x = -unit_y
453
+
454
+ perp_y = unit_x
455
+
456
+ base_center = QtCore.QPointF(
457
+
458
+ tip.x() - unit_x * arrow_length,
459
+
460
+ tip.y() - unit_y * arrow_length,
461
+
462
+ )
463
+
464
+ half_width = arrow_width / 2.0
465
+
466
+ left_point = QtCore.QPointF(
467
+
468
+ base_center.x() + perp_x * half_width,
469
+
470
+ base_center.y() + perp_y * half_width,
471
+
472
+ )
473
+
474
+ right_point = QtCore.QPointF(
475
+
476
+ base_center.x() - perp_x * half_width,
477
+
478
+ base_center.y() - perp_y * half_width,
479
+
480
+ )
481
+
482
+ return [
483
+
484
+ tip,
485
+
486
+ left_point,
487
+
488
+ right_point,
489
+
490
+ ]
491
+
492
+ def export_drawsvg_py(scene: QtWidgets.QGraphicsScene, parent: QtWidgets.QWidget | None = None):
493
+
494
+ shape_items = [it for it in scene.items() if it.data(0) in SHAPES]
495
+
496
+ if shape_items:
497
+
498
+ rect = shape_items[0].sceneBoundingRect()
499
+
500
+ for it in shape_items[1:]:
501
+
502
+ rect = rect.united(it.sceneBoundingRect())
503
+
504
+ else:
505
+
506
+ rect = scene.itemsBoundingRect()
507
+
508
+ padding = 5.0
509
+
510
+ rect = rect.adjusted(-padding, -padding, padding, padding)
511
+
512
+ left = math.floor(rect.left())
513
+
514
+ top = math.floor(rect.top())
515
+
516
+ right = math.ceil(rect.right())
517
+
518
+ bottom = math.ceil(rect.bottom())
519
+
520
+ width = max(1, int(right - left))
521
+
522
+ height = max(1, int(bottom - top))
523
+
524
+ ox = int(left)
525
+
526
+ oy = int(top)
527
+
528
+ items = list(reversed(shape_items))
529
+
530
+ label_counter = 0
531
+
532
+ lines = []
533
+
534
+ lines.append("# Auto-generated from PySide6 Canvas to drawsvg")
535
+
536
+ lines.append("import drawsvg as draw")
537
+
538
+ lines.append("")
539
+
540
+ lines.append("def build_drawing():")
541
+
542
+ lines.append(f" d = draw.Drawing({width}, {height}, origin=({ox}, {oy}), viewBox='{ox} {oy} {width} {height}')")
543
+
544
+ lines.append(
545
+
546
+ f" d.append(draw.Rectangle({ox}, {oy}, {width}, {height}, fill='white', stroke='none'))"
547
+
548
+ )
549
+
550
+ lines.append("")
551
+
552
+ for it in items:
553
+
554
+ shape = it.data(0)
555
+
556
+ if shape in ("Rectangle", "Rounded Rectangle") and isinstance(
557
+
558
+ it, QtWidgets.QGraphicsRectItem
559
+
560
+ ):
561
+
562
+ r = it.rect()
563
+
564
+ x = it.pos().x()
565
+
566
+ y = it.pos().y()
567
+
568
+ w = r.width()
569
+
570
+ h = r.height()
571
+
572
+ cx = x + w / 2.0
573
+
574
+ cy = y + h / 2.0
575
+
576
+ ang = it.rotation()
577
+
578
+ rx = getattr(it, "rx", 0)
579
+
580
+ ry = getattr(it, "ry", 0)
581
+
582
+ label_id = None
583
+
584
+ if isinstance(it, RectItem) and getattr(it, "has_label", lambda: False)():
585
+
586
+ label_counter += 1
587
+
588
+ label_id = f"rect_label_{label_counter}"
589
+
590
+ extra_attrs = []
591
+
592
+ if label_id:
593
+
594
+ extra_attrs.append(f"data_label_id='{label_id}'")
595
+
596
+ if rx:
597
+
598
+ extra_attrs.append(f"rx={rx:.2f}")
599
+
600
+ if ry:
601
+
602
+ extra_attrs.append(f"ry={ry:.2f}")
603
+
604
+ attr_str = _format_item_attributes(it, extra_attrs=extra_attrs)
605
+
606
+ if abs(ang) > 1e-6:
607
+
608
+ lines.append(
609
+
610
+ f" _rect = draw.Rectangle({x:.2f}, {y:.2f}, {w:.2f}, {h:.2f}, {attr_str}, transform='rotate({ang:.2f} {cx:.2f} {cy:.2f})')"
611
+
612
+ )
613
+
614
+ else:
615
+
616
+ lines.append(
617
+
618
+ f" _rect = draw.Rectangle({x:.2f}, {y:.2f}, {w:.2f}, {h:.2f}, {attr_str})"
619
+
620
+ )
621
+
622
+ lines.append(" d.append(_rect)")
623
+
624
+ if label_id:
625
+
626
+ _export_shape_label(
627
+
628
+ it,
629
+
630
+ lines,
631
+
632
+ shape_id=label_id,
633
+
634
+ angle=ang,
635
+
636
+ base_pos=(x, y),
637
+
638
+ base_size=(w, h),
639
+
640
+ var_name="rect_label",
641
+
642
+ label_kind="rect",
643
+
644
+ )
645
+
646
+ lines.append("")
647
+
648
+ elif shape == "Split Rounded Rectangle" and isinstance(it, SplitRoundedRectItem):
649
+
650
+ r = it.rect()
651
+
652
+ x = it.pos().x()
653
+
654
+ y = it.pos().y()
655
+
656
+ w = r.width()
657
+
658
+ h = r.height()
659
+
660
+ cx = x + w / 2.0
661
+
662
+ cy = y + h / 2.0
663
+
664
+ ang = it.rotation()
665
+
666
+ rx_raw = getattr(it, "rx", 0.0)
667
+
668
+ ry_raw = getattr(it, "ry", rx_raw)
669
+
670
+ extra_attrs = []
671
+
672
+ if rx_raw:
673
+
674
+ extra_attrs.append(f"rx={rx_raw:.2f}")
675
+
676
+ if ry_raw:
677
+
678
+ extra_attrs.append(f"ry={ry_raw:.2f}")
679
+
680
+ attr_str = _format_item_attributes(it, extra_attrs=extra_attrs)
681
+
682
+ ratio = it.divider_ratio()
683
+
684
+ top_brush = it.topBrush()
685
+
686
+ if top_brush.style() == QtCore.Qt.BrushStyle.NoBrush:
687
+
688
+ top_fill = "none"
689
+
690
+ top_opacity = 1.0
691
+
692
+ else:
693
+
694
+ top_color = top_brush.color()
695
+
696
+ top_fill = top_color.name()
697
+
698
+ top_opacity = top_color.alphaF()
699
+
700
+ lines.append(
701
+
702
+ f" # SplitRoundedRect ratio={ratio:.6f} top_fill='{top_fill}' top_opacity={top_opacity:.3f}"
703
+
704
+ )
705
+
706
+ if abs(ang) > 1e-6:
707
+
708
+ lines.append(
709
+
710
+ f" _split_rect = draw.Rectangle({x:.2f}, {y:.2f}, {w:.2f}, {h:.2f}, {attr_str}, transform='rotate({ang:.2f} {cx:.2f} {cy:.2f})')"
711
+
712
+ )
713
+
714
+ else:
715
+
716
+ lines.append(
717
+
718
+ f" _split_rect = draw.Rectangle({x:.2f}, {y:.2f}, {w:.2f}, {h:.2f}, {attr_str})"
719
+
720
+ )
721
+
722
+ lines.append(" d.append(_split_rect)")
723
+
724
+ rect_scene = QtCore.QRectF(x, y, w, h)
725
+
726
+ rx = max(0.0, min(rx_raw, w / 2.0, 50.0))
727
+
728
+ ry = max(0.0, min(ry_raw, h / 2.0, 50.0))
729
+
730
+ base_path = QtGui.QPainterPath()
731
+
732
+ if rx > 0.0 or ry > 0.0:
733
+
734
+ base_path.addRoundedRect(rect_scene, rx, ry)
735
+
736
+ else:
737
+
738
+ base_path.addRect(rect_scene)
739
+
740
+ line_y = y + h * ratio
741
+
742
+ line_y = max(y, min(y + h, line_y))
743
+
744
+ top_height = max(0.0, line_y - y)
745
+
746
+ if top_height > 0.0 and top_brush.style() != QtCore.Qt.BrushStyle.NoBrush:
747
+
748
+ top_clip = QtGui.QPainterPath()
749
+
750
+ top_clip.addRect(x, y, w, top_height)
751
+
752
+ top_path = base_path.intersected(top_clip)
753
+
754
+ path_cmd = _painter_path_to_svg(top_path)
755
+
756
+ if path_cmd:
757
+
758
+ top_attrs = [f"fill='{top_fill}'", "stroke='none'"]
759
+
760
+ if top_fill != "none" and top_opacity < 1.0:
761
+
762
+ top_attrs.append(f"fill_opacity={top_opacity:.2f}")
763
+
764
+ attr = ", ".join(top_attrs)
765
+
766
+ if abs(ang) > 1e-6:
767
+
768
+ lines.append(
769
+
770
+ f" _split_top = draw.Path('{path_cmd}', {attr}, transform='rotate({ang:.2f} {cx:.2f} {cy:.2f})')"
771
+
772
+ )
773
+
774
+ else:
775
+
776
+ lines.append(f" _split_top = draw.Path('{path_cmd}', {attr})")
777
+
778
+ lines.append(" d.append(_split_top)")
779
+
780
+ divider_pen = getattr(it, "_divider_pen", it.pen())
781
+
782
+ divider_attrs = [
783
+
784
+ f"stroke='{divider_pen.color().name()}'",
785
+
786
+ f"stroke_width={divider_pen.widthF():.2f}",
787
+
788
+ ]
789
+
790
+ divider_attr = ", ".join(divider_attrs)
791
+
792
+ x2 = x + w
793
+
794
+ if abs(ang) > 1e-6:
795
+
796
+ lines.append(
797
+
798
+ f" _split_div = draw.Line({x:.2f}, {line_y:.2f}, {x2:.2f}, {line_y:.2f}, {divider_attr}, transform='rotate({ang:.2f} {cx:.2f} {cy:.2f})')"
799
+
800
+ )
801
+
802
+ else:
803
+
804
+ lines.append(
805
+
806
+ f" _split_div = draw.Line({x:.2f}, {line_y:.2f}, {x2:.2f}, {line_y:.2f}, {divider_attr})"
807
+
808
+ )
809
+
810
+ lines.append(" d.append(_split_div)")
811
+
812
+ lines.append("")
813
+
814
+ elif shape == "Ellipse" and isinstance(it, QtWidgets.QGraphicsEllipseItem):
815
+
816
+ r = it.rect()
817
+
818
+ x = it.pos().x()
819
+
820
+ y = it.pos().y()
821
+
822
+ w = r.width()
823
+
824
+ h = r.height()
825
+
826
+ cx = x + w / 2.0
827
+
828
+ cy = y + h / 2.0
829
+
830
+ rx = w / 2.0
831
+
832
+ ry = h / 2.0
833
+
834
+ ang = it.rotation()
835
+
836
+ label_id = None
837
+
838
+ if isinstance(it, ShapeLabelMixin) and getattr(it, "has_label", lambda: False)():
839
+
840
+ label_counter += 1
841
+
842
+ label_id = f"ellipse_label_{label_counter}"
843
+
844
+ extra_attrs: list[str] = []
845
+
846
+ if label_id:
847
+
848
+ extra_attrs.append(f"data_label_id='{label_id}'")
849
+
850
+ attr_str = _format_item_attributes(it, extra_attrs=extra_attrs)
851
+
852
+ if abs(ang) > 1e-6:
853
+
854
+ lines.append(
855
+
856
+ f" _ell = draw.Ellipse({cx:.2f}, {cy:.2f}, {rx:.2f}, {ry:.2f}, {attr_str}, transform='rotate({ang:.2f} {cx:.2f} {cy:.2f})')"
857
+
858
+ )
859
+
860
+ else:
861
+
862
+ lines.append(
863
+
864
+ f" _ell = draw.Ellipse({cx:.2f}, {cy:.2f}, {rx:.2f}, {ry:.2f}, {attr_str})"
865
+
866
+ )
867
+
868
+ lines.append(" d.append(_ell)")
869
+
870
+ if label_id:
871
+
872
+ _export_shape_label(
873
+
874
+ it,
875
+
876
+ lines,
877
+
878
+ shape_id=label_id,
879
+
880
+ angle=ang,
881
+
882
+ base_pos=(x, y),
883
+
884
+ base_size=(w, h),
885
+
886
+ var_name="ellipse_label",
887
+
888
+ label_kind="ellipse",
889
+
890
+ )
891
+
892
+ lines.append("")
893
+
894
+ elif shape == "Circle" and isinstance(it, QtWidgets.QGraphicsEllipseItem):
895
+
896
+ r = it.rect()
897
+
898
+ x = it.pos().x()
899
+
900
+ y = it.pos().y()
901
+
902
+ w = r.width()
903
+
904
+ h = r.height()
905
+
906
+ d_avg = (w + h) / 2.0
907
+
908
+ radius = d_avg / 2.0
909
+
910
+ cx = x + w / 2.0
911
+
912
+ cy = y + h / 2.0
913
+
914
+ ang = it.rotation()
915
+
916
+ label_id = None
917
+
918
+ if isinstance(it, ShapeLabelMixin) and getattr(it, "has_label", lambda: False)():
919
+
920
+ label_counter += 1
921
+
922
+ label_id = f"circle_label_{label_counter}"
923
+
924
+ extra_attrs: list[str] = []
925
+
926
+ if label_id:
927
+
928
+ extra_attrs.append(f"data_label_id='{label_id}'")
929
+
930
+ attr_str = _format_item_attributes(it, extra_attrs=extra_attrs)
931
+
932
+ if abs(ang) > 1e-6:
933
+
934
+ lines.append(
935
+
936
+ f" _circ = draw.Circle({cx:.2f}, {cy:.2f}, {radius:.2f}, {attr_str}, transform='rotate({ang:.2f} {cx:.2f} {cy:.2f})')"
937
+
938
+ )
939
+
940
+ else:
941
+
942
+ lines.append(
943
+
944
+ f" _circ = draw.Circle({cx:.2f}, {cy:.2f}, {radius:.2f}, {attr_str})"
945
+
946
+ )
947
+
948
+ lines.append(" d.append(_circ)")
949
+
950
+ if label_id:
951
+
952
+ _export_shape_label(
953
+
954
+ it,
955
+
956
+ lines,
957
+
958
+ shape_id=label_id,
959
+
960
+ angle=ang,
961
+
962
+ base_pos=(x, y),
963
+
964
+ base_size=(w, h),
965
+
966
+ var_name="circle_label",
967
+
968
+ label_kind="circle",
969
+
970
+ )
971
+
972
+ lines.append("")
973
+
974
+ elif shape == "Triangle" and isinstance(it, QtWidgets.QGraphicsPolygonItem):
975
+
976
+ poly = it.polygon()
977
+
978
+ x = it.pos().x()
979
+
980
+ y = it.pos().y()
981
+
982
+ pts = []
983
+
984
+ for p in poly:
985
+
986
+ pts.extend([x + p.x(), y + p.y()])
987
+
988
+ br = it.boundingRect()
989
+
990
+ cx = x + br.width() / 2.0
991
+
992
+ cy = y + br.height() / 2.0
993
+
994
+ ang = it.rotation()
995
+
996
+ attr_str = _format_item_attributes(it)
997
+
998
+ coord_str = ", ".join(f"{v:.2f}" for v in pts)
999
+
1000
+ if abs(ang) > 1e-6:
1001
+
1002
+ lines.append(
1003
+
1004
+ f" _tri = draw.Lines({coord_str}, close=True, {attr_str}, transform='rotate({ang:.2f} {cx:.2f} {cy:.2f})')"
1005
+
1006
+ )
1007
+
1008
+ else:
1009
+
1010
+ lines.append(
1011
+
1012
+ f" _tri = draw.Lines({coord_str}, close=True, {attr_str})"
1013
+
1014
+ )
1015
+
1016
+ lines.append(" d.append(_tri)")
1017
+
1018
+ lines.append("")
1019
+
1020
+ elif shape == "Diamond" and isinstance(it, DiamondItem):
1021
+
1022
+ poly = it.polygon()
1023
+
1024
+ x = it.pos().x()
1025
+
1026
+ y = it.pos().y()
1027
+
1028
+ pts: list[float] = []
1029
+
1030
+ for p in poly:
1031
+
1032
+ pts.extend([x + p.x(), y + p.y()])
1033
+
1034
+ br = it.boundingRect()
1035
+
1036
+ cx = x + br.x() + br.width() / 2.0
1037
+
1038
+ cy = y + br.y() + br.height() / 2.0
1039
+
1040
+ ang = it.rotation()
1041
+
1042
+ label_id = None
1043
+
1044
+ if isinstance(it, ShapeLabelMixin) and getattr(it, "has_label", lambda: False)():
1045
+
1046
+ label_counter += 1
1047
+
1048
+ label_id = f"diamond_label_{label_counter}"
1049
+
1050
+ extra_attrs = []
1051
+
1052
+ if label_id:
1053
+
1054
+ extra_attrs.append(f"data_label_id='{label_id}'")
1055
+
1056
+ attr_str = _format_item_attributes(it, extra_attrs=extra_attrs)
1057
+
1058
+ coord_str = ", ".join(f"{v:.2f}" for v in pts)
1059
+
1060
+ if abs(ang) > 1e-6:
1061
+
1062
+ lines.append(
1063
+
1064
+ f" _diamond = draw.Lines({coord_str}, close=True, {attr_str}, transform='rotate({ang:.2f} {cx:.2f} {cy:.2f})')"
1065
+
1066
+ )
1067
+
1068
+ else:
1069
+
1070
+ lines.append(
1071
+
1072
+ f" _diamond = draw.Lines({coord_str}, close=True, {attr_str})"
1073
+
1074
+ )
1075
+
1076
+ lines.append(" d.append(_diamond)")
1077
+
1078
+ if label_id:
1079
+
1080
+ base_pos = (x + br.x(), y + br.y())
1081
+
1082
+ base_size = (br.width(), br.height())
1083
+
1084
+ _export_shape_label(
1085
+
1086
+ it,
1087
+
1088
+ lines,
1089
+
1090
+ shape_id=label_id,
1091
+
1092
+ angle=ang,
1093
+
1094
+ base_pos=base_pos,
1095
+
1096
+ base_size=base_size,
1097
+
1098
+ var_name="diamond_label",
1099
+
1100
+ label_kind="diamond",
1101
+
1102
+ )
1103
+
1104
+ lines.append("")
1105
+
1106
+ elif shape == "Block Arrow" and isinstance(it, BlockArrowItem):
1107
+
1108
+ poly = it.polygon()
1109
+
1110
+ x = it.pos().x()
1111
+
1112
+ y = it.pos().y()
1113
+
1114
+ pts: list[float] = []
1115
+
1116
+ for p in poly:
1117
+
1118
+ pts.extend([x + p.x(), y + p.y()])
1119
+
1120
+ br = it.boundingRect()
1121
+
1122
+ cx = x + br.width() / 2.0
1123
+
1124
+ cy = y + br.height() / 2.0
1125
+
1126
+ ang = it.rotation()
1127
+
1128
+ attr_str = _format_item_attributes(it)
1129
+
1130
+ lines.append(
1131
+
1132
+ f" # BlockArrow head_ratio={it.head_ratio():.6f} shaft_ratio={it.shaft_ratio():.6f}"
1133
+
1134
+ )
1135
+
1136
+ coord_str = ", ".join(f"{v:.2f}" for v in pts)
1137
+
1138
+ if abs(ang) > 1e-6:
1139
+
1140
+ lines.append(
1141
+
1142
+ f" _block_arrow = draw.Lines({coord_str}, close=True, {attr_str}, transform='rotate({ang:.2f} {cx:.2f} {cy:.2f})')"
1143
+
1144
+ )
1145
+
1146
+ else:
1147
+
1148
+ lines.append(
1149
+
1150
+ f" _block_arrow = draw.Lines({coord_str}, close=True, {attr_str})"
1151
+
1152
+ )
1153
+
1154
+ lines.append(" d.append(_block_arrow)")
1155
+
1156
+ lines.append("")
1157
+
1158
+ elif shape == "Curvy Right Bracket" and isinstance(it, CurvyBracketItem):
1159
+
1160
+ x = it.pos().x()
1161
+
1162
+ y = it.pos().y()
1163
+
1164
+ w = it.width()
1165
+
1166
+ h = it.height()
1167
+
1168
+ cx = x + w / 2.0
1169
+
1170
+ cy = y + h / 2.0
1171
+
1172
+ ang = it.rotation()
1173
+
1174
+ path = QtGui.QPainterPath(it.path())
1175
+
1176
+ path.translate(x, y)
1177
+
1178
+ path_cmd = _painter_path_to_svg(path)
1179
+
1180
+ if not path_cmd:
1181
+
1182
+ continue
1183
+
1184
+ attr_str = _format_item_attributes(it)
1185
+
1186
+ lines.append(
1187
+
1188
+ f" # CurvyBracket x={x:.2f} y={y:.2f} w={w:.2f} h={h:.2f} hook_ratio={it.hook_ratio():.6f}"
1189
+
1190
+ )
1191
+
1192
+ if abs(ang) > 1e-6:
1193
+
1194
+ lines.append(
1195
+
1196
+ f" _path = draw.Path('{path_cmd}', {attr_str}, transform='rotate({ang:.2f} {cx:.2f} {cy:.2f})')"
1197
+
1198
+ )
1199
+
1200
+ else:
1201
+
1202
+ lines.append(f" _path = draw.Path('{path_cmd}', {attr_str})")
1203
+
1204
+ lines.append(" d.append(_path)")
1205
+
1206
+ lines.append("")
1207
+
1208
+ elif shape in ("Line", "Arrow") and isinstance(it, LineItem):
1209
+
1210
+ pen = it.pen()
1211
+
1212
+ ang = it.rotation()
1213
+
1214
+ pos = it.pos()
1215
+
1216
+ origin = it.transformOriginPoint()
1217
+
1218
+ cx = pos.x() + origin.x()
1219
+
1220
+ cy = pos.y() + origin.y()
1221
+
1222
+ points = [
1223
+
1224
+ QtCore.QPointF(pos.x() + p.x(), pos.y() + p.y()) for p in it._points
1225
+
1226
+ ]
1227
+
1228
+ if not points:
1229
+
1230
+ continue
1231
+
1232
+ path_cmd = "M " + " L ".join(f"{pt.x():.2f} {pt.y():.2f}" for pt in points)
1233
+
1234
+ attrs = [
1235
+
1236
+ f"stroke='{pen.color().name()}'",
1237
+
1238
+ f"stroke_width={pen.widthF():.2f}",
1239
+
1240
+ "fill='none'",
1241
+
1242
+ ]
1243
+
1244
+ dash_str = _pen_dash_array_string(pen)
1245
+
1246
+ if dash_str:
1247
+
1248
+ attrs.append(f"stroke_dasharray='{dash_str}'")
1249
+
1250
+ arrow_start = getattr(it, "arrow_start", False)
1251
+ arrow_end = getattr(it, "arrow_end", False)
1252
+ arrow_length = float(
1253
+ getattr(it, "_arrow_head_length", getattr(it, "_arrow_size", 10.0))
1254
+ )
1255
+ arrow_width = float(
1256
+ getattr(it, "_arrow_head_width", getattr(it, "_arrow_size", 10.0))
1257
+ )
1258
+
1259
+ if arrow_start or arrow_end:
1260
+ attrs.append(f"data_arrow_start={'True' if arrow_start else 'False'}")
1261
+ attrs.append(f"data_arrow_end={'True' if arrow_end else 'False'}")
1262
+ attrs.append(f"data_arrow_head_length={arrow_length:.2f}")
1263
+ attrs.append(f"data_arrow_head_width={arrow_width:.2f}")
1264
+
1265
+ attr_str = ", ".join(attrs)
1266
+
1267
+ transform_suffix = ""
1268
+
1269
+ if abs(ang) > 1e-6:
1270
+
1271
+ transform_suffix = f", transform='rotate({ang:.2f} {cx:.2f} {cy:.2f})'"
1272
+
1273
+ lines.append(f" _path = draw.Path('{path_cmd}', {attr_str}{transform_suffix})")
1274
+
1275
+ if arrow_start or arrow_end:
1276
+
1277
+ start_flag = "true" if arrow_start else "false"
1278
+
1279
+ end_flag = "true" if arrow_end else "false"
1280
+
1281
+ lines.append(
1282
+
1283
+ f" # Arrowheads: start={start_flag}, end={end_flag}, length={arrow_length:.2f}, width={arrow_width:.2f}"
1284
+
1285
+ )
1286
+
1287
+ local_polys: list[list[QtCore.QPointF]] = []
1288
+
1289
+ if arrow_start and len(it._points) >= 2:
1290
+
1291
+ local_polys.append(
1292
+
1293
+ _arrowhead_polygon(
1294
+ it._points[1], it._points[0], arrow_length, arrow_width
1295
+ )
1296
+
1297
+ )
1298
+
1299
+ if arrow_end and len(it._points) >= 2:
1300
+
1301
+ local_polys.append(
1302
+
1303
+ _arrowhead_polygon(
1304
+
1305
+ it._points[-2],
1306
+ it._points[-1],
1307
+ arrow_length,
1308
+ arrow_width,
1309
+
1310
+ )
1311
+
1312
+ )
1313
+
1314
+ color = pen.color()
1315
+
1316
+ arrow_attrs = [
1317
+
1318
+ f"fill='{color.name()}'",
1319
+
1320
+ f"stroke='{color.name()}'",
1321
+
1322
+ f"stroke_width={pen.widthF():.2f}",
1323
+
1324
+ ]
1325
+
1326
+ if color.alphaF() < 1.0:
1327
+
1328
+ arrow_attrs.append(f"fill_opacity={color.alphaF():.2f}")
1329
+
1330
+ arrow_attrs.append(f"stroke_opacity={color.alphaF():.2f}")
1331
+
1332
+ arrow_attr_str = ", ".join(arrow_attrs)
1333
+
1334
+ for poly in local_polys:
1335
+
1336
+ abs_poly = [
1337
+
1338
+ QtCore.QPointF(pos.x() + p.x(), pos.y() + p.y()) for p in poly
1339
+
1340
+ ]
1341
+
1342
+ arrow_cmd = (
1343
+
1344
+ "M "
1345
+
1346
+ + " L ".join(
1347
+
1348
+ f"{pt.x():.2f} {pt.y():.2f}" for pt in abs_poly
1349
+
1350
+ )
1351
+
1352
+ + " Z"
1353
+
1354
+ )
1355
+
1356
+ lines.append(
1357
+
1358
+ f" _arrow_head = draw.Path('{arrow_cmd}', {arrow_attr_str}{transform_suffix})"
1359
+
1360
+ )
1361
+
1362
+ lines.append(" d.append(_arrow_head)")
1363
+
1364
+ lines.append(" d.append(_path)")
1365
+
1366
+ lines.append("")
1367
+
1368
+ elif shape == "Text" and isinstance(it, QtWidgets.QGraphicsTextItem):
1369
+
1370
+ br = it.boundingRect()
1371
+
1372
+ s = it.scale() or 1.0
1373
+
1374
+ cx = it.pos().x() + br.width() / 2.0
1375
+
1376
+ cy = it.pos().y() + br.height() / 2.0
1377
+
1378
+ x_top = cx - (br.width() * s) / 2.0
1379
+
1380
+ y_top = cy - (br.height() * s) / 2.0
1381
+
1382
+ ang = it.rotation()
1383
+
1384
+ font = it.font()
1385
+
1386
+ fm = QtGui.QFontMetricsF(font)
1387
+
1388
+ # robuste Pixelgröße aus Font bestimmen
1389
+
1390
+ pixel_size = float(font.pixelSize())
1391
+
1392
+ if pixel_size <= 0.0:
1393
+
1394
+ point_size = font.pointSizeF()
1395
+
1396
+ if point_size > 0.0:
1397
+
1398
+ screen = QtGui.QGuiApplication.primaryScreen()
1399
+
1400
+ dpi = screen.logicalDotsPerInch() if screen else 96.0
1401
+
1402
+ pixel_size = point_size * dpi / 72.0
1403
+
1404
+ if pixel_size <= 0.0:
1405
+
1406
+ pixel_size = fm.height()
1407
+
1408
+ size = pixel_size * s
1409
+
1410
+ raw_text = it.toPlainText()
1411
+ text_lines: list[str]
1412
+ doc = it.document()
1413
+ layout = doc.documentLayout() if doc is not None else None
1414
+ if doc is not None and layout is not None:
1415
+ text_lines = []
1416
+ block = doc.begin()
1417
+ while block.isValid():
1418
+ layout.blockBoundingRect(block)
1419
+ block_text = block.text()
1420
+ block_layout = block.layout()
1421
+ if block_layout is not None and block_layout.lineCount() > 0:
1422
+ for idx in range(block_layout.lineCount()):
1423
+ line = block_layout.lineAt(idx)
1424
+ start = line.textStart()
1425
+ length = line.textLength()
1426
+ fragment = block_text[start : start + length]
1427
+ text_lines.append(fragment)
1428
+ else:
1429
+ text_lines.append(block_text)
1430
+ block = block.next()
1431
+ if not text_lines:
1432
+ text_lines = [""]
1433
+ else:
1434
+ text_lines = raw_text.splitlines()
1435
+ if raw_text.endswith(("\r", "\n")):
1436
+ text_lines.append("")
1437
+ if not text_lines:
1438
+ text_lines = [raw_text]
1439
+
1440
+ line_px = fm.lineSpacing() * s
1441
+ line_ratio = line_px / size if size > 0.0 else 1.0
1442
+
1443
+ color = it.defaultTextColor()
1444
+ doc_margin = it.document().documentMargin() if it.document() else 0.0
1445
+ h_align = v_align = None
1446
+ if hasattr(it, "text_alignment"):
1447
+ try:
1448
+ h_align, v_align = it.text_alignment()
1449
+ except Exception:
1450
+ h_align = v_align = None
1451
+ text_dir = None
1452
+ if hasattr(it, "text_direction"):
1453
+ try:
1454
+ text_dir = it.text_direction()
1455
+ except Exception:
1456
+ text_dir = None
1457
+
1458
+ text_x = x_top + doc_margin * s
1459
+ text_y = y_top + doc_margin * s
1460
+
1461
+ base_attrs = [
1462
+ f"fill='{color.name()}'",
1463
+ f"font_family='{font.family()}'",
1464
+ "text_anchor='start'",
1465
+ "dominant_baseline='text-before-edge'",
1466
+ "alignment_baseline='text-before-edge'",
1467
+ f"line_height={line_ratio:.6f}",
1468
+ "xml__space='preserve'",
1469
+ f"data_doc_margin={doc_margin:.4f}",
1470
+ f"data_font_px={pixel_size:.4f}",
1471
+ f"data_scale={s:.6f}",
1472
+ ]
1473
+ base_attrs.append(f"data_box_w={br.width():.4f}")
1474
+ base_attrs.append(f"data_box_h={br.height():.4f}")
1475
+ if h_align:
1476
+ base_attrs.append(f"data_text_h='{h_align}'")
1477
+ if v_align:
1478
+ base_attrs.append(f"data_text_v='{v_align}'")
1479
+ if text_dir:
1480
+ base_attrs.append(f"data_text_dir='{text_dir}'")
1481
+ if color.alphaF() < 1.0:
1482
+ base_attrs.append(f"fill_opacity={color.alphaF():.2f}")
1483
+ base_attr_str = ", ".join(base_attrs)
1484
+
1485
+ json_lines = [line if line else "\u00A0" for line in text_lines]
1486
+ if len(json_lines) == 1:
1487
+ text_literal = json.dumps(json_lines[0], ensure_ascii=False)
1488
+ else:
1489
+ text_literal = json.dumps(json_lines, ensure_ascii=False)
1490
+
1491
+ transform_suffix = ""
1492
+ if abs(ang) > 1e-6:
1493
+ transform_suffix = f", transform='rotate({ang:.2f} {cx:.2f} {cy:.2f})'"
1494
+
1495
+ lines.append(
1496
+ f" _text = draw.Text({text_literal}, {size:.2f}, {text_x:.2f}, {text_y:.2f}, {base_attr_str}{transform_suffix})"
1497
+ )
1498
+ lines.append(" d.append(_text)")
1499
+ lines.append("")
1500
+
1501
+ elif shape == "Folder Tree" and isinstance(it, FolderTreeItem):
1502
+
1503
+ structure_json = json.dumps(it.structure(), ensure_ascii=False)
1504
+
1505
+ pos = it.pos()
1506
+
1507
+ rotation = it.rotation()
1508
+
1509
+ br = it.boundingRect()
1510
+
1511
+ transform = it.sceneTransform()
1512
+
1513
+ matrix = (
1514
+
1515
+ f"matrix({transform.m11():.6f} {transform.m12():.6f} {transform.m21():.6f} "
1516
+
1517
+ f"{transform.m22():.6f} {transform.m31():.2f} {transform.m32():.2f})"
1518
+
1519
+ )
1520
+
1521
+ lines.append(
1522
+
1523
+ f" # FolderTree pos=({pos.x():.2f}, {pos.y():.2f}) size=({br.width():.2f}, {br.height():.2f}) rotation={rotation:.2f} structure={structure_json}"
1524
+
1525
+ )
1526
+
1527
+ lines.append(f" _folder_tree = draw.Group(transform='{matrix}')")
1528
+
1529
+ line_pen = getattr(it, "_line_pen", QtGui.QPen(QtGui.QColor("#7a7a7a")))
1530
+
1531
+ folder_pen = getattr(it, "_folder_pen", QtGui.QPen(QtGui.QColor("#9bd97c")))
1532
+
1533
+ file_pen = getattr(it, "_file_pen", QtGui.QPen(QtGui.QColor("#f58db2")))
1534
+
1535
+ font = getattr(it, "_font", QtGui.QFont(DEFAULT_FONT_FAMILY, 11))
1536
+
1537
+ fm = QtGui.QFontMetricsF(font)
1538
+
1539
+ dot_radius = float(getattr(it, "_dot_radius", 6.0))
1540
+
1541
+ offset = dot_radius - 1.0
1542
+
1543
+ order = list(getattr(it, "_order", []))
1544
+
1545
+ info_map = getattr(it, "_node_info", {})
1546
+
1547
+ line_attr = (
1548
+
1549
+ f"stroke='{line_pen.color().name()}', stroke_width={line_pen.widthF():.2f}"
1550
+
1551
+ )
1552
+
1553
+ for node in order:
1554
+
1555
+ node_parent = getattr(node, "parent", None)
1556
+
1557
+ if node_parent is None:
1558
+
1559
+ continue
1560
+
1561
+ info = info_map.get(node)
1562
+
1563
+ parent_info = info_map.get(node_parent)
1564
+
1565
+ if not info or not parent_info:
1566
+
1567
+ continue
1568
+
1569
+ parent_center = parent_info.get("dot_center")
1570
+
1571
+ child_center = info.get("dot_center")
1572
+
1573
+ if parent_center is None or child_center is None:
1574
+
1575
+ continue
1576
+
1577
+ start = QtCore.QPointF(parent_center.x(), parent_center.y() + offset)
1578
+
1579
+ end = QtCore.QPointF(parent_center.x(), child_center.y())
1580
+
1581
+ lines.append(
1582
+
1583
+ f" _folder_tree.append(draw.Line({start.x():.2f}, {start.y():.2f}, {end.x():.2f}, {end.y():.2f}, {line_attr}))"
1584
+
1585
+ )
1586
+
1587
+ horizontal_start = QtCore.QPointF(parent_center.x(), child_center.y())
1588
+
1589
+ horizontal_end = QtCore.QPointF(
1590
+
1591
+ child_center.x() - (dot_radius - 1.0), child_center.y()
1592
+
1593
+ )
1594
+
1595
+ lines.append(
1596
+
1597
+ f" _folder_tree.append(draw.Line({horizontal_start.x():.2f}, {horizontal_start.y():.2f}, {horizontal_end.x():.2f}, {horizontal_end.y():.2f}, {line_attr}))"
1598
+
1599
+ )
1600
+
1601
+ for node in order:
1602
+
1603
+ info = info_map.get(node)
1604
+
1605
+ if not info:
1606
+
1607
+ continue
1608
+
1609
+ text_rect = info.get("text_rect")
1610
+
1611
+ if text_rect is None:
1612
+
1613
+ continue
1614
+
1615
+ label = it._node_label(node)
1616
+
1617
+ text = repr(label)[1:-1]
1618
+
1619
+ text_x = text_rect.left()
1620
+
1621
+ center_y = text_rect.center().y()
1622
+
1623
+ baseline = center_y + (fm.ascent() - fm.descent()) / 2.0
1624
+
1625
+ pen = folder_pen if getattr(node, "is_folder", False) else file_pen
1626
+
1627
+ color = pen.color()
1628
+
1629
+ font_size = font.pointSizeF()
1630
+
1631
+ if font_size <= 0.0:
1632
+
1633
+ font_size = float(font.pixelSize())
1634
+
1635
+ attrs = [
1636
+
1637
+ f"fill='{color.name()}'",
1638
+
1639
+ f"font_family='{font.family()}'",
1640
+
1641
+ ]
1642
+
1643
+ if color.alphaF() < 1.0:
1644
+
1645
+ attrs.append(f"fill_opacity={color.alphaF():.2f}")
1646
+
1647
+ attr_str = ", ".join(attrs)
1648
+
1649
+ lines.append(
1650
+
1651
+ f" _folder_tree.append(draw.Text('{text}', {font_size:.2f}, {text_x:.2f}, {baseline:.2f}, {attr_str}))"
1652
+
1653
+ )
1654
+
1655
+ lines.append(" d.append(_folder_tree)")
1656
+
1657
+ lines.append("")
1658
+
1659
+ lines.append(" return d")
1660
+
1661
+ lines.append("")
1662
+
1663
+ lines.append("if __name__ == '__main__':")
1664
+
1665
+ lines.append(" d = build_drawing()")
1666
+
1667
+ lines.append(" # Creates an SVG file next to the script:")
1668
+
1669
+ lines.append(" d.save_svg('canvas.svg')")
1670
+
1671
+ code = "\n".join(lines)
1672
+
1673
+ path, _ = QtWidgets.QFileDialog.getSaveFileName(
1674
+
1675
+ parent,
1676
+
1677
+ "Save as drawsvg-.py…",
1678
+
1679
+ "canvas_drawsvg.py",
1680
+
1681
+ "Python (*.py)",
1682
+
1683
+ )
1684
+
1685
+ if path:
1686
+
1687
+ try:
1688
+
1689
+ with open(path, "w", encoding="utf-8") as f:
1690
+
1691
+ f.write(code)
1692
+
1693
+ if parent is not None:
1694
+
1695
+ parent.statusBar().showMessage(f"Exported: {path}", 5000)
1696
+
1697
+ except Exception as e:
1698
+
1699
+ QtWidgets.QMessageBox.critical(parent, "Error saving file", str(e))
1700
+