drawio-python 0.1.5__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 CHANGED
@@ -1 +1 @@
1
- 0.1.5
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
- issues = check(document, fit=fit, pages=args.page or None)
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.box if shape.label else (shape.box[0], shape.box[1], 0, 0)
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 check(document, *, fit: Optional[Fit] = None, pages=None) -> list[Issue]:
522
- """Run every layout check on a Document (or only on the pages whose IDs are given)."""
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