Extend the verified label-mask width range to 200px while retaining the 14px height cap, with adversarial and shipped-asset coverage, and publish synchronized plugin version 2.4.7.
169 lines
5.1 KiB
Python
169 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Verify no diagram label mask is clipped by a node painted after it.
|
|
|
|
SKILL.md §6 keeps an arrow label 6-10px clear of its connector, and §5 fixes the
|
|
paint order as background -> zones -> arrows -> labels -> nodes. Nothing keeps a
|
|
label mask off a *node*, so a label whose mask lands partly inside a node
|
|
rectangle that is painted later gets covered by the node fill: the text renders
|
|
as a fragment sitting on the node border.
|
|
|
|
Paint order is what makes this a defect rather than a stylistic choice:
|
|
|
|
* A mask overlapping a zone container is fine - zones are painted before labels,
|
|
so the label stays on top. Zone eyebrows rely on this.
|
|
* A mask overlapping a node declared *later* in the document is clipped by that
|
|
node. That is the failure this check reports.
|
|
|
|
Shape heuristics follow the shipped templates:
|
|
|
|
* A node is a `<rect>` at least 60x40 - large enough for a title and sublabel.
|
|
* A label mask is a `<rect>` 20-200 wide and 8-14 tall - the masking plate that
|
|
SKILL.md prescribes for arrow labels and zone eyebrows. The width cap covers
|
|
the long mono plates shipped in example-sequence-oauth.html (128px) and the
|
|
wider plates CJK labels need at the same glyph count.
|
|
* A mask fully contained in a node is a badge chip (`EXT`, `EDGE`, `ORIG`) and
|
|
is legal.
|
|
|
|
Usage:
|
|
python3 scripts/verify-geometry.py --all
|
|
python3 scripts/verify-geometry.py skills/diagram-design/assets/example-x.html
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
ASSET_DIR = ROOT / "skills/diagram-design/assets"
|
|
|
|
RECT_RE = re.compile(
|
|
r"<rect\b[^>]*?"
|
|
r'\bx="(?P<x>-?[\d.]+)"\s+'
|
|
r'y="(?P<y>-?[\d.]+)"\s+'
|
|
r'width="(?P<w>[\d.]+)"\s+'
|
|
r'height="(?P<h>[\d.]+)"',
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
NODE_MIN_W = 60.0
|
|
NODE_MIN_H = 40.0
|
|
MASK_MIN_W = 20.0
|
|
MASK_MAX_W = 200.0
|
|
MASK_MIN_H = 8.0
|
|
MASK_MAX_H = 14.0
|
|
EPSILON = 0.5
|
|
|
|
|
|
class Rect:
|
|
__slots__ = ("x", "y", "w", "h", "line", "offset")
|
|
|
|
def __init__(self, x, y, w, h, line, offset) -> None:
|
|
self.x, self.y, self.w, self.h = x, y, w, h
|
|
self.line, self.offset = line, offset
|
|
|
|
@property
|
|
def right(self) -> float:
|
|
return self.x + self.w
|
|
|
|
@property
|
|
def bottom(self) -> float:
|
|
return self.y + self.h
|
|
|
|
def __repr__(self) -> str:
|
|
return f"({self.x:g},{self.y:g} {self.w:g}x{self.h:g})"
|
|
|
|
|
|
def parse_rects(source: str) -> list[Rect]:
|
|
rects: list[Rect] = []
|
|
for match in RECT_RE.finditer(source):
|
|
rects.append(
|
|
Rect(
|
|
float(match.group("x")),
|
|
float(match.group("y")),
|
|
float(match.group("w")),
|
|
float(match.group("h")),
|
|
source.count("\n", 0, match.start()) + 1,
|
|
match.start(),
|
|
)
|
|
)
|
|
return rects
|
|
|
|
|
|
def overlap(a: Rect, b: Rect) -> tuple[float, float]:
|
|
return (
|
|
min(a.right, b.right) - max(a.x, b.x),
|
|
min(a.bottom, b.bottom) - max(a.y, b.y),
|
|
)
|
|
|
|
|
|
def contained(inner: Rect, outer: Rect) -> bool:
|
|
return (
|
|
inner.x >= outer.x - EPSILON
|
|
and inner.y >= outer.y - EPSILON
|
|
and inner.right <= outer.right + EPSILON
|
|
and inner.bottom <= outer.bottom + EPSILON
|
|
)
|
|
|
|
|
|
def check(path: Path) -> list[str]:
|
|
source = path.read_text(encoding="utf-8")
|
|
rects = parse_rects(source)
|
|
nodes = [r for r in rects if r.w >= NODE_MIN_W and r.h >= NODE_MIN_H]
|
|
masks = [
|
|
r
|
|
for r in rects
|
|
if MASK_MIN_W <= r.w <= MASK_MAX_W and MASK_MIN_H <= r.h <= MASK_MAX_H
|
|
]
|
|
|
|
findings: list[str] = []
|
|
for mask in masks:
|
|
for node in nodes:
|
|
if node.offset <= mask.offset:
|
|
continue # painted before the label; the label stays on top
|
|
dx, dy = overlap(mask, node)
|
|
if dx <= 1.0 or dy <= 1.0 or contained(mask, node):
|
|
continue
|
|
findings.append(
|
|
f"{path.name}:{mask.line}: label mask {mask} is clipped by node "
|
|
f"{node} declared later at line {node.line} (overlap {dx:g}x{dy:g}px)"
|
|
f" - move the label onto a free segment of its connector"
|
|
)
|
|
break
|
|
return findings
|
|
|
|
|
|
def targets(args: argparse.Namespace) -> list[Path]:
|
|
if args.all:
|
|
return sorted(ASSET_DIR.glob("*.html"))
|
|
return [Path(p) for p in args.files]
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("files", nargs="*", help="HTML diagrams to check")
|
|
parser.add_argument("--all", action="store_true", help="check every shipped asset")
|
|
args = parser.parse_args()
|
|
|
|
paths = targets(args)
|
|
if not paths:
|
|
parser.error("pass one or more files, or --all")
|
|
|
|
findings: list[str] = []
|
|
for path in paths:
|
|
if not path.exists():
|
|
findings.append(f"{path}: file not found")
|
|
continue
|
|
findings.extend(check(path))
|
|
|
|
for finding in findings:
|
|
print(finding)
|
|
print(f"Summary: {len(paths)} file(s) checked, {len(findings)} finding(s).")
|
|
return 1 if findings else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|