#!/usr/bin/env python3 """Overlap audit for the generated displays. python check_layout.py [display.xml ...] Reads the built XML and reports text that collides with other text, or that escapes its panel. Written after a units relabel pushed the wet well caption into the top ladder label and nobody noticed until the screens were on a monitor - the generator places components by arithmetic, so a collision is arithmetic too, and worth catching here rather than in CI View. Text width is estimated, not measured: the value font is Consolas, which is monospaced at ~0.55 em, and the label font is close enough at this size. So the check is approximate by construction - it is a net for gross collisions, not a typesetter. Some components no longer HAVE one position. A level mark bound to its setpoint through `.y` sits wherever the operator last typed, so checking it where the generator happened to draw it checks the one arrangement that is guaranteed not to be the problem. Those are swept across their whole input range instead - see `sweep`. """ import re import sys from pathlib import Path HERE = Path(__file__).resolve().parent CHAR_W = 0.55 # em, Consolas advance width PAD = 2.0 # px of slack before two boxes count as colliding def components(xml): """(kind, x1, y1, x2, y2, value, size) for everything positioned.""" out = [] for kind, body in re.findall( r'<(rectangle|text|number|ellipse) type="componentData">(.*?)', xml, re.S): def num(tag): m = re.search(r"<%s>(-?[\d.]+)" % (tag, tag), body) return float(m.group(1)) if m else None cx, cy = num("x"), num("y") top, bot, left, right = num("top"), num("bottom"), num("left"), num("right") if None in (cx, cy, top, bot, left, right): continue val = re.search(r"(.*?)", body) size = re.search(r'size="(\d+)"', body) out.append((kind, cx - left, cy - top, cx + right, cy + bot, val.group(1) if val else "", int(size.group(1)) if size else 0, " w * 1.5: x1 = x2 - w else: x2 = x1 + w return x1, y1, x2, y2 def overlaps(a, b): ax1, ay1, ax2, ay2 = text_box(a) bx1, by1, bx2, by2 = text_box(b) return not (ax2 - PAD <= bx1 or bx2 - PAD <= ax1 or ay2 - PAD <= by1 or by2 - PAD <= ay1) def line_boxes(xml): """Process lines as thin boxes - the obstacles text must not sit on. THE GAP THIS CLOSES. The first version of this checker compared text against text only, so it passed a screen whose top row of values was sitting on the inlet pipe and whose data column ran across the discharge manifold. It measured what was easy, not what was wrong. """ out = [] for body in re.findall(r'(.*?)', xml, re.S): pts = [float(v) for v in re.findall(r"<(?:x|y)>(-?[\d.]+)", body)] if len(pts) < 2: continue cx, cy = pts[0], pts[1] def num(tag): m = re.search(r"<%s>(-?[\d.]+)" % (tag, tag), body) return float(m.group(1)) if m else 0.0 l, r_, t, b = num("left"), num("right"), num("top"), num("bottom") out.append(("line", cx - l, cy - t, cx + r_, cy + b, "", 0, False, 0, 0)) return out def shared_data(xml): """globalSection data objects by id - where a component's actions live.""" return dict(re.findall( r'(.*?)', xml, re.S)) def movers(xml): """Components whose y is bound to an item, with the law that moves them. Yields (component, y_at_input_0, y_at_input_end, input_end, item). The component tuple is the one `components()` produces, so it drops straight into the same overlap test. """ shared = shared_data(xml) out = [] for kind, body in re.findall( r'<(rectangle|text|number) type="componentData">(.*?)', xml, re.S): ref = re.search(r'', body) if not ref or ref.group(1) not in shared: continue m = re.search(r'name="%s\.y"/>.*?([\d.]+)' r"(-?[\d.]+)" r"(-?[\d.]+).*?" r"([^<]+)" % kind, shared[ref.group(1)], re.S) if not m: continue end, y0, y1 = float(m.group(1)), float(m.group(2)), float(m.group(3)) # a number carries no ; its mask is what it will be as wide as val = re.search(r"(.*?)", body) fmt = re.search(r"([^<]*)", body) text = val.group(1) if val else (fmt.group(1) if fmt else "") for c in components("<%s type=\"componentData\">%s" % (kind, body, kind)): out.append((c[:5] + (text,) + c[6:], y0, y1, end, m.group(4))) return out def sweep(xml, statics, step=0.5): """Drive every moving component across its range against what stays put. The invariant being enforced is that a mark which can be moved and a mark which cannot never share a column - because a mark that moves will eventually be driven to the height of one that does not. Checking the positions the generator drew proves nothing about that; only the sweep does. Moving-against-moving is deliberately NOT reported. Two setpoints set close together really do put their marks close together, and that is the screen telling the truth about the thresholds, not a layout fault. """ hits = {} for c, y0, y1, end, item in movers(xml): cy = (c[2] + c[4]) / 2.0 v = 0.0 while v <= end + 1e-9: dy = (y0 + (y1 - y0) * v / end) - cy moved = (c[0], c[1], c[2] + dy, c[3], c[4] + dy) + c[5:] for s in statics: if overlaps(moved, s): key = (item, c[5] or c[0], s[5] or s[0]) lo, hi = hits.get(key, (v, v)) hits[key] = (min(lo, v), max(hi, v)) v += step return hits def audit(path): xml = path.read_text(encoding="utf-8") comps = components(xml) texts = [c for c in comps if c[0] == "text" and c[5].strip()] lines = line_boxes(xml) hits = [] for i, a in enumerate(texts): for b in texts[i + 1:]: if overlaps(a, b) and not stacked(a, b): hits.append((a[5], b[5])) for a in texts: for b in lines: ax1, ay1, ax2, ay2 = text_box(a) if not (ax2 <= b[1] or b[3] <= ax1 or ay2 <= b[2] or b[4] <= ay1): hits.append((a[5], "" % (b[1], b[2]))) # things that stay put, which is what a moving mark must never reach moving_at = {(round(c[1], 1), round(c[2], 1)) for c, _, _, _, _ in movers(xml)} statics = [t for t in texts if (round(t[1], 1), round(t[2], 1)) not in moving_at] swept = sweep(xml, statics + lines) n = len(hits) + len(swept) print("%-28s %3d components, %3d text, %2d moving" % (path.name, len(comps), len(texts), len(moving_at)), end="") if n: print(" -> %d COLLISIONS" % n) for a, b in hits: print(" %-40s x %s" % (a[:40], b[:40])) for (item, a, b), (lo, hi) in sorted(swept.items()): print(" %-22s %-16s x %-22s at %g-%g%%" % (item.replace("AID.WRPS.", ""), a[:16], b[:22], lo, hi)) else: print(" -> clear") return n def main(): args = sys.argv[1:] files = [Path(a) for a in args] if args else sorted((HERE / "out").glob("WRPS_*.xml")) bad = sum(audit(f) for f in files if f.is_file()) sys.exit(1 if bad else 0) if __name__ == "__main__": main()