drawio-python 0.1.4__py3-none-any.whl → 0.1.6__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.
- drawio/VERSION +1 -1
- drawio/cli.py +20 -1
- drawio/lint.py +154 -4
- drawio/render/desktop.py +18 -3
- drawio/routing.py +676 -0
- drawio/scene.py +14 -242
- drawio/skill/SKILL.md +11 -3
- drawio/skill/references/api.md +5 -3
- drawio/text.py +8 -5
- {drawio_python-0.1.4.dist-info → drawio_python-0.1.6.dist-info}/METADATA +13 -4
- {drawio_python-0.1.4.dist-info → drawio_python-0.1.6.dist-info}/RECORD +15 -14
- {drawio_python-0.1.4.dist-info → drawio_python-0.1.6.dist-info}/WHEEL +0 -0
- {drawio_python-0.1.4.dist-info → drawio_python-0.1.6.dist-info}/entry_points.txt +0 -0
- {drawio_python-0.1.4.dist-info → drawio_python-0.1.6.dist-info}/licenses/LICENSE +0 -0
- {drawio_python-0.1.4.dist-info → drawio_python-0.1.6.dist-info}/top_level.txt +0 -0
drawio/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.1.
|
|
1
|
+
0.1.6
|
drawio/cli.py
CHANGED
|
@@ -95,7 +95,17 @@ def _cmd_lint(args):
|
|
|
95
95
|
document = Document.load(args.input)
|
|
96
96
|
document.validate()
|
|
97
97
|
fit = parse_fit(args.fit, args.min_pt) if args.fit else None
|
|
98
|
-
|
|
98
|
+
|
|
99
|
+
def codes(values):
|
|
100
|
+
return [c.strip() for v in values or [] for c in v.split(",") if c.strip()] or None
|
|
101
|
+
|
|
102
|
+
issues = check(
|
|
103
|
+
document,
|
|
104
|
+
fit=fit,
|
|
105
|
+
pages=args.page or None,
|
|
106
|
+
select=codes(args.select),
|
|
107
|
+
ignore=codes(args.ignore),
|
|
108
|
+
)
|
|
99
109
|
threshold = SEVERITIES.index(args.fail_on)
|
|
100
110
|
failing = [i for i in issues if SEVERITIES.index(i.severity) <= threshold]
|
|
101
111
|
counts = {s: sum(i.severity == s for i in issues) for s in SEVERITIES}
|
|
@@ -109,10 +119,14 @@ def _cmd_lint(args):
|
|
|
109
119
|
)
|
|
110
120
|
else:
|
|
111
121
|
for issue in issues:
|
|
122
|
+
if issue.severity == "info" and not args.verbose:
|
|
123
|
+
continue
|
|
112
124
|
print(f"{issue.severity}: [{issue.code}] page {issue.page}: {issue.message}")
|
|
113
125
|
if issue.hint:
|
|
114
126
|
print(f" fix: {issue.hint}")
|
|
115
127
|
summary = ", ".join(f"{n} {s}(s)" for s, n in counts.items() if n)
|
|
128
|
+
if counts["info"] and not args.verbose:
|
|
129
|
+
summary += " (-v shows the info lines)"
|
|
116
130
|
print(f"{args.input}: {summary or 'no layout problems found'}")
|
|
117
131
|
return 1 if failing else 0
|
|
118
132
|
|
|
@@ -332,6 +346,11 @@ def _parser():
|
|
|
332
346
|
" smaller than --min-pt",
|
|
333
347
|
)
|
|
334
348
|
lint.add_argument("--min-pt", type=float, default=12)
|
|
349
|
+
lint.add_argument(
|
|
350
|
+
"--select", action="append", help="Only these codes (comma list; edge-* for a prefix)"
|
|
351
|
+
)
|
|
352
|
+
lint.add_argument("--ignore", action="append", help="Drop these codes (comma list)")
|
|
353
|
+
lint.add_argument("-v", "--verbose", action="store_true", help="Also print info findings")
|
|
335
354
|
lint.add_argument(
|
|
336
355
|
"--fail-on",
|
|
337
356
|
choices=["error", "warning", "info"],
|
drawio/lint.py
CHANGED
|
@@ -203,7 +203,7 @@ def check_scene(scene: Scene, fit: Optional[Fit] = None) -> list[Issue]:
|
|
|
203
203
|
|
|
204
204
|
def footprint(shape: Shape) -> Box:
|
|
205
205
|
if shape.id in textlike:
|
|
206
|
-
return shape.label
|
|
206
|
+
return _ink(shape.label) if shape.label else (shape.box[0], shape.box[1], 0, 0)
|
|
207
207
|
return shape.box
|
|
208
208
|
|
|
209
209
|
def kind(shape: Shape) -> str:
|
|
@@ -425,6 +425,57 @@ def check_scene(scene: Scene, fit: Optional[Fit] = None) -> list[Issue]:
|
|
|
425
425
|
)
|
|
426
426
|
)
|
|
427
427
|
|
|
428
|
+
# Connectors covered by an opaque shape drawn after them (draw.io paints in order).
|
|
429
|
+
for edge in connectors:
|
|
430
|
+
total = sum(math.dist(a, b) for a, b in zip(edge.points, edge.points[1:]))
|
|
431
|
+
if total <= 0:
|
|
432
|
+
continue
|
|
433
|
+
ends = {e for e in (edge.source, edge.target) if e}
|
|
434
|
+
protected = set(ends)
|
|
435
|
+
for end in ends:
|
|
436
|
+
protected |= lineage.get(end, set())
|
|
437
|
+
for shape in shapes:
|
|
438
|
+
if shape.id in protected or depth[shape.id] < depth[edge.id] or not _opaque(shape):
|
|
439
|
+
continue
|
|
440
|
+
box = _shrink(shape.box, 1) # a connector along the outline stays visible
|
|
441
|
+
if shape.kind in _ROUND:
|
|
442
|
+
box = _shrink(box, min(box[2], box[3]) * 0.15)
|
|
443
|
+
hidden = sum(_inside_length(a, b, box) for a, b in zip(edge.points, edge.points[1:]))
|
|
444
|
+
# Half the line, or the arrow head: a mark placed over a short stretch on purpose
|
|
445
|
+
# (a cross on a broken link) is not reported.
|
|
446
|
+
if hidden >= total * 0.5 or _contains_point(box, edge.points[-1]):
|
|
447
|
+
issues.append(
|
|
448
|
+
Issue(
|
|
449
|
+
"edge-hidden",
|
|
450
|
+
"warning",
|
|
451
|
+
page,
|
|
452
|
+
[edge.id, shape.id],
|
|
453
|
+
f"Connector {edge.id} is drawn under {_name(shape)}, which covers"
|
|
454
|
+
f" {hidden:.0f} of its {total:.0f} px"
|
|
455
|
+
+ (
|
|
456
|
+
" including the arrow head."
|
|
457
|
+
if _contains_point(box, edge.points[-1])
|
|
458
|
+
else "."
|
|
459
|
+
),
|
|
460
|
+
"draw.io paints cells in order: create the connector after the shape, give"
|
|
461
|
+
" the shape fillColor=none, or move it off the route.",
|
|
462
|
+
{"route": [list(p) for p in edge.points], "shape": _round(shape.box)},
|
|
463
|
+
)
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
for item in scene.items:
|
|
467
|
+
for problem in _style_problems(item.style):
|
|
468
|
+
issues.append(
|
|
469
|
+
Issue(
|
|
470
|
+
"style-ignored",
|
|
471
|
+
"info",
|
|
472
|
+
page,
|
|
473
|
+
[item.id],
|
|
474
|
+
f"draw.io ignores the style property {problem[0]!r} on {item.id}.",
|
|
475
|
+
problem[1],
|
|
476
|
+
)
|
|
477
|
+
)
|
|
478
|
+
|
|
428
479
|
for shape in shapes:
|
|
429
480
|
if not shape.known:
|
|
430
481
|
issues.append(
|
|
@@ -518,13 +569,112 @@ def _name(shape: Shape) -> str:
|
|
|
518
569
|
return shape.id
|
|
519
570
|
|
|
520
571
|
|
|
521
|
-
def
|
|
522
|
-
"""
|
|
572
|
+
def _ink(label: Label) -> Box:
|
|
573
|
+
"""The part of a label's line boxes that glyphs cover: no leading above the first line,
|
|
574
|
+
no space under the last line's descenders."""
|
|
575
|
+
x, y, w, h = label.box
|
|
576
|
+
top, bottom = 0.18 * label.font_size, 0.09 * label.font_size
|
|
577
|
+
return x, y + top, w, max(h - top - bottom, 0)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _opaque(shape: Shape) -> bool:
|
|
581
|
+
if shape.kind in ("text", "image", "line") and shape.style.get("fillColor") in (None, "none"):
|
|
582
|
+
return False
|
|
583
|
+
fill = shape.style.get("fillColor", "#ffffff")
|
|
584
|
+
if fill in ("none", ""):
|
|
585
|
+
return False
|
|
586
|
+
try:
|
|
587
|
+
alpha = float(shape.style.get("opacity", 100)) * float(shape.style.get("fillOpacity", 100))
|
|
588
|
+
except ValueError:
|
|
589
|
+
alpha = 10000
|
|
590
|
+
return alpha >= 5000
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _contains_point(box: Box, p) -> bool:
|
|
594
|
+
return box[0] < p[0] < box[0] + box[2] and box[1] < p[1] < box[1] + box[3]
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _inside_length(a, b, box: Box) -> float:
|
|
598
|
+
"""Length of segment a-b inside box (Liang-Barsky clipping)."""
|
|
599
|
+
x0, y0 = a
|
|
600
|
+
dx, dy = b[0] - x0, b[1] - y0
|
|
601
|
+
t0, t1 = 0.0, 1.0
|
|
602
|
+
for p, q in ((-dx, x0 - box[0]), (dx, box[0] + box[2] - x0),
|
|
603
|
+
(-dy, y0 - box[1]), (dy, box[1] + box[3] - y0)): # fmt: skip
|
|
604
|
+
if p == 0:
|
|
605
|
+
if q < 0:
|
|
606
|
+
return 0.0
|
|
607
|
+
else:
|
|
608
|
+
t = q / p
|
|
609
|
+
if p < 0:
|
|
610
|
+
t0 = max(t0, t)
|
|
611
|
+
else:
|
|
612
|
+
t1 = min(t1, t)
|
|
613
|
+
return max(0.0, t1 - t0) * math.hypot(dx, dy)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
# Shape names that only work as `shape=NAME` or as a bare style name, never as NAME=1.
|
|
617
|
+
_SHAPE_KEYS = {
|
|
618
|
+
"ellipse", "doubleEllipse", "rhombus", "triangle", "cloud", "hexagon", "cylinder",
|
|
619
|
+
"swimlane", "image", "line", "text", "actor", "process", "note", "card", "step",
|
|
620
|
+
"document", "parallelogram", "trapezoid", "circle", "diamond",
|
|
621
|
+
} # fmt: skip
|
|
622
|
+
# Common style properties, to catch case mistakes such as fontsize for fontSize.
|
|
623
|
+
_KNOWN_KEYS = {
|
|
624
|
+
"fillColor", "strokeColor", "fontColor", "fontSize", "fontFamily", "fontStyle",
|
|
625
|
+
"strokeWidth", "dashed", "dashPattern", "rounded", "arcSize", "absoluteArcSize",
|
|
626
|
+
"whiteSpace", "html", "align", "verticalAlign", "labelPosition", "verticalLabelPosition",
|
|
627
|
+
"spacing", "spacingTop", "spacingLeft", "spacingRight", "spacingBottom", "opacity",
|
|
628
|
+
"fillOpacity", "strokeOpacity", "textOpacity", "shadow", "glass", "gradientColor",
|
|
629
|
+
"gradientDirection", "edgeStyle", "curved", "endArrow", "startArrow", "endFill",
|
|
630
|
+
"startFill", "endSize", "startSize", "exitX", "exitY", "entryX", "entryY", "exitDx",
|
|
631
|
+
"exitDy", "entryDx", "entryDy", "exitPerimeter", "entryPerimeter", "labelBackgroundColor",
|
|
632
|
+
"labelBorderColor", "container", "collapsible", "recursiveResize", "shape", "perimeter",
|
|
633
|
+
"aspect", "image", "imageAspect", "direction", "rotation", "flipH", "flipV", "size",
|
|
634
|
+
"fixedSize", "boundedLbl", "backgroundOutline", "horizontal", "jettySize", "elbow",
|
|
635
|
+
"orthogonalLoop", "noLabel", "editable", "movable", "resizable", "deletable",
|
|
636
|
+
"connectable", "portConstraint", "swimlaneFillColor", "swimlaneLine", "childLayout",
|
|
637
|
+
} # fmt: skip
|
|
638
|
+
_BY_LOWER = {k.lower(): k for k in _KNOWN_KEYS}
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _style_problems(style: dict) -> list[tuple[str, str]]:
|
|
642
|
+
found = []
|
|
643
|
+
for key, value in style.items():
|
|
644
|
+
if value is not None and key in _SHAPE_KEYS and key not in _KNOWN_KEYS:
|
|
645
|
+
name = {"circle": "ellipse", "diamond": "rhombus", "cylinder": "cylinder3"}.get(
|
|
646
|
+
key, key
|
|
647
|
+
)
|
|
648
|
+
found.append((f"{key}={value}", f"Use shape={name} (or page.box(..., shape={key!r}))."))
|
|
649
|
+
elif key not in _KNOWN_KEYS and key.lower() in _BY_LOWER:
|
|
650
|
+
found.append((key, f"Property names are case-sensitive: use {_BY_LOWER[key.lower()]}."))
|
|
651
|
+
return found
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _wanted(code: str, select, ignore) -> bool:
|
|
655
|
+
if select and not any(code == s or code.startswith(s.rstrip("*")) for s in select):
|
|
656
|
+
return False
|
|
657
|
+
return not (ignore and any(code == s or code.startswith(s.rstrip("*")) for s in ignore))
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def check(
|
|
661
|
+
document,
|
|
662
|
+
*,
|
|
663
|
+
fit: Optional[Fit] = None,
|
|
664
|
+
pages=None,
|
|
665
|
+
select=None,
|
|
666
|
+
ignore=None,
|
|
667
|
+
) -> list[Issue]:
|
|
668
|
+
"""Run every layout check on a Document (or only on the pages whose IDs are given).
|
|
669
|
+
|
|
670
|
+
`select` keeps only the listed finding codes and `ignore` drops them; a trailing `*`
|
|
671
|
+
matches a prefix, e.g. ignore=["edge-*"].
|
|
672
|
+
"""
|
|
523
673
|
result = []
|
|
524
674
|
for page in document.pages:
|
|
525
675
|
if pages and page.id not in pages and page.name not in pages:
|
|
526
676
|
continue
|
|
527
|
-
result.extend(check_scene(build(page), fit))
|
|
677
|
+
result.extend(i for i in check_scene(build(page), fit) if _wanted(i.code, select, ignore))
|
|
528
678
|
return result
|
|
529
679
|
|
|
530
680
|
|
drawio/render/desktop.py
CHANGED
|
@@ -12,6 +12,7 @@ import os
|
|
|
12
12
|
import shutil
|
|
13
13
|
import subprocess
|
|
14
14
|
import sys
|
|
15
|
+
import sysconfig
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
from typing import Optional
|
|
17
18
|
|
|
@@ -34,13 +35,27 @@ _CANDIDATES = {
|
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
def _is_ours(path: str) -> bool:
|
|
37
|
-
"""
|
|
38
|
+
"""Whether `path` is the `drawio` command of a Python package rather than draw.io desktop.
|
|
39
|
+
|
|
40
|
+
pip installs that command as a script with a Python shebang on Linux and macOS, and as
|
|
41
|
+
an .exe launcher with the script appended (uncompressed) on Windows. Anything inside a
|
|
42
|
+
Python scripts directory counts as ours too.
|
|
43
|
+
"""
|
|
44
|
+
resolved = Path(path).resolve()
|
|
45
|
+
scripts = {sysconfig.get_path("scripts"), str(Path(sys.executable).parent)}
|
|
46
|
+
if any(d and Path(d).resolve() == resolved.parent for d in scripts):
|
|
47
|
+
return True
|
|
38
48
|
try:
|
|
39
|
-
with open(
|
|
49
|
+
with open(resolved, "rb") as stream:
|
|
40
50
|
head = stream.read(256)
|
|
51
|
+
stream.seek(0, os.SEEK_END)
|
|
52
|
+
stream.seek(max(0, stream.tell() - 65536))
|
|
53
|
+
tail = stream.read()
|
|
41
54
|
except OSError:
|
|
42
55
|
return False
|
|
43
|
-
|
|
56
|
+
if head.startswith(b"#!") and b"python" in head.lower():
|
|
57
|
+
return True
|
|
58
|
+
return b"drawio.__main__" in tail or b"from drawio" in tail
|
|
44
59
|
|
|
45
60
|
|
|
46
61
|
def find() -> Optional[str]:
|