#!/usr/bin/env python3 """그림 안에서 상자와 라벨이 서로를 덮는지 본다. `technical-visualizer` 스킬이 적어 둔 사각지대를 기계로 옮긴 것이다. **lint 도 검사기도 라벨이 상자를 덮는 것은 못 잡는다.** lint 는 의미(관계가 이어져 있는가)를 보고 `check-figure-text.py` 는 글자가 이름인가를 본다. 둘 다 **좌표**를 안 본다. 그래서 zone 둘이 겹쳐 그려지거나 라벨이 상자에 먹혀도 통과한다. 실제로 keycloak AP4 그림을 다시 만들었을 때 Compose zone 이 Host zone 을 삼키고 `Host network` 라벨이 `Compose network` 라벨 뒤에 숨었는데 lint 는 PASS 였다. python3 scripts/check-figure-overlap.py [프로젝트 ...] python3 scripts/check-figure-overlap.py --file 그림.svg renderer 가 배경 사각형을 정확한 좌표로 남기므로 글자 폭을 어림하지 않는다 — `edge-label-bg` 와 `group-label-bg` 가 그 라벨이 실제로 차지하는 자리다. """ from __future__ import annotations import argparse import glob import os import re import sys ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) RECT = re.compile(r"]*)>") ATTR = re.compile(r'(\w[\w-]*)="([^"]*)"') TEXT = re.compile(r']*)>([^<]*)') # 겹치면 안 되는 짝. (왼쪽 종류, 오른쪽 종류, 무엇이 잘못됐나) # group-label-bg 가 group-box 위에 앉는 것과 node-shape 가 group-box 안에 드는 것은 설계다. PAIRS = ( ("group-box", "group-box", "구역 둘이 겹쳐 그려졌다"), ("group-label-bg", "group-label-bg", "구역 이름이 서로를 덮는다"), ("node-shape", "node-shape", "상자 둘이 겹쳐 그려졌다"), ("edge-label-bg", "node-shape", "이음 라벨이 상자에 먹혔다"), ("edge-label-bg", "edge-label-bg", "이음 라벨끼리 겹친다"), ) # 겹침으로 세려면 가로·세로가 둘 다 이만큼은 넘어야 한다. # 넓이만 보면 상자가 클수록 예민해진다 — 0.5px 이 50px 짜리 변을 만나면 25px² 이다 EPS = 1.0 def _kind(cls: str) -> str: if "group-box" in cls: return "group-box" if "group-label-bg" in cls: return "group-label-bg" if "edge-label-bg" in cls: return "edge-label-bg" if "node-shape" in cls: return "node-shape" return "" def boxes(svg: str) -> list[tuple[str, float, float, float, float]]: """(종류, x, y, w, h). 좌표가 없는 것은 뺀다 — canvas 가 그렇다.""" out = [] for attrs in RECT.findall(svg): a = dict(ATTR.findall(attrs)) kind = _kind(a.get("class", "")) if not kind: continue try: x, y = float(a["x"]), float(a["y"]) w, h = float(a["width"]), float(a["height"]) except (KeyError, ValueError): continue out.append((kind, x, y, w, h)) return out def labels(svg: str) -> list[tuple[float, float, str]]: """(x, y, 글자). 겹친 자리에 무엇이 있었는지 말해 주려고 쓴다.""" out = [] for attrs, body in TEXT.findall(svg): a = dict(ATTR.findall(attrs)) try: out.append((float(a["x"]), float(a["y"]), body.strip())) except (KeyError, ValueError): continue return out def _overlap(a, b) -> float: """겹친 넓이. 맞닿기만 한 것과 한쪽으로만 얇게 스친 것은 0 이다.""" _, ax, ay, aw, ah = a _, bx, by, bw, bh = b dx = min(ax + aw, bx + bw) - max(ax, bx) dy = min(ay + ah, by + bh) - max(ay, by) if dx <= EPS or dy <= EPS: return 0.0 return dx * dy def _contains(a, b) -> bool: """a 가 b 를 통째로 품는가. 구역 안의 구역은 설계이지 겹침이 아니다.""" _, ax, ay, aw, ah = a _, bx, by, bw, bh = b return ax <= bx and ay <= by and ax + aw >= bx + bw and ay + ah >= by + bh def _near(lbls, box, limit=2) -> str: """상자 안에 든 글자. 어느 라벨이 문제인지 이름으로 말해 준다.""" _, x, y, w, h = box hit = [t for lx, ly, t in lbls if t and x - 2 <= lx <= x + w + 2 and y - 14 <= ly <= y + h + 2] return " · ".join(hit[:limit]) def check(path: str) -> list[str]: try: svg = open(path, encoding="utf-8").read() except OSError as exc: return [f"읽지 못했다 — {exc}"] bs = boxes(svg) lbls = labels(svg) found: list[str] = [] for i, a in enumerate(bs): for b in bs[i + 1:]: for left, right, why in PAIRS: if {a[0], b[0]} != {left, right} if left != right else not ( a[0] == left and b[0] == left): continue if left == right == "group-box" and (_contains(a, b) or _contains(b, a)): continue area = _overlap(a, b) if area <= 0: continue names = " / ".join(n for n in (_near(lbls, a), _near(lbls, b)) if n) found.append(f"{why} — {int(area)}px²{' ' + names if names else ''}") break return found def main() -> int: ap = argparse.ArgumentParser(description="그림 안에서 상자와 라벨이 겹치는지 본다.") ap.add_argument("projects", nargs="*") ap.add_argument("--file", action="append", default=[], help="SVG 를 직접 준다") ap.add_argument("--samples", type=int, default=4) args = ap.parse_args() files = list(args.file) if not files: names = args.projects or sorted( os.path.basename(os.path.dirname(os.path.dirname(p))) for p in glob.glob(os.path.join(ROOT, "docs/*/final/document.md"))) for name in names: files += sorted(glob.glob( os.path.join(ROOT, "docs", name, "final/assets/**/*.svg"), recursive=True)) if not files: print("볼 그림이 없다", file=sys.stderr) return 0 bad = 0 for path in files: hits = check(path) if not hits: continue bad += 1 rel = os.path.relpath(path, ROOT) print(f"✗ {rel}") for line in hits[:args.samples]: print(f" · {line}") if len(hits) > args.samples: print(f" … 외 {len(hits) - args.samples}건") print(f"FIGURE OVERLAP: {'FAIL' if bad else 'PASS'} — 그림 {len(files)} · 겹친 그림 {bad}") return 1 if bad else 0 if __name__ == "__main__": raise SystemExit(main())