audit_diagrams: intelege <g transform=translate>, si nu mai inventeaza SCARA GRESITA
Cauza celor 46 de raportari: unealta citea coordonatele brute ale etichetelor, deci compara panouri stivuite ca si cum ar fi in acelasi sistem. Acum tine o stiva de translatii la parsare si scoate coordonate absolute, inclusiv pentru <g>-uri imbricate. Si verificarea de scara ruleaza doar pe un triplet real SL/intrare/TP, nu pe orice trei perechi linie+eticheta. 46 -> 7 pe cele 92 de diagrame, fara nicio raportare noua. Are --selftest, cu cazul care picase si cu unul care trebuie sa ramana prins. Doua dintre cele 7 ramase sunt probleme reale, nestiute pana acum: atmi_ansamblu are un caption de 792 px intr-un viewBox de 720, iar la selectia_proximitate se calca eticheta sagetii cu titlul. Cinci sunt tot fantome, din alt defect (pragul de inaltime ignora font-size) - raportat de agent, nereparat inca. Spec-ul si docstring-ul lui atmi_exemplu erau si ele ramase in urma: descriau un desen weekly de 38 de candele, desenul e lunar, de 31. Specificatiile .spec.txt: cerute doar cand desenul il face alt agent decat cel care scrie capitolul. Sunt o predare intre doi agenti, nu arhiva. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ThN7ixZrowca8aTGrK1MUJ
This commit is contained in:
@@ -7,6 +7,7 @@ linii goale care rup blocul HTML. NU înlocuiește verificarea vizuală — nu
|
||||
vede dacă un text stă peste o linie de preț.
|
||||
|
||||
Rulează: .venv/bin/python audit_diagrams.py summaries/<modul>/<fisier>.md
|
||||
Selftest: .venv/bin/python audit_diagrams.py --selftest
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -16,60 +17,156 @@ from pathlib import Path
|
||||
CHAR_W = 5.7 # latime medie per caracter la 12px sans-serif (calibrat pe randare)
|
||||
LINE_H = 13 # înălțimea unei linii de text
|
||||
|
||||
_TRANSFORM = re.compile(r'translate\(\s*([-\d.]+)\s*[, ]\s*([-\d.]+)\s*\)')
|
||||
_ELEMENT = re.compile(
|
||||
r'<g\b([^>]*)>|</g>|<text\b([^>]*)>(.*?)</text>|<line\b([^>]*?)/>|<[^>]+>', re.S)
|
||||
_ROLE = re.compile(r'SL\b|stop', re.I)
|
||||
_ROLE_ENTRY = re.compile(r'intrar|entry', re.I)
|
||||
_ROLE_TP = re.compile(r'\bTP\d*|\bțint|\btarget', re.I)
|
||||
|
||||
|
||||
def _elements(svg: str):
|
||||
"""Evenimentele SVG în ordine, cu translația `<g>` acumulată aplicată.
|
||||
|
||||
Fiecare `<g transform="translate(dx,dy)">` deschide un cadru; elementele
|
||||
dinăuntru moștenesc deplasarea, oricât de adânc ar fi imbricate.
|
||||
"""
|
||||
stack = [(0.0, 0.0)]
|
||||
for m in _ELEMENT.finditer(svg):
|
||||
if m.group(1) is not None: # <g ...>
|
||||
t = _TRANSFORM.search(m.group(1))
|
||||
dx, dy = (float(t.group(1)), float(t.group(2))) if t else (0.0, 0.0)
|
||||
px, py = stack[-1]
|
||||
stack.append((px + dx, py + dy))
|
||||
elif m.group(0) == "</g>":
|
||||
if len(stack) > 1:
|
||||
stack.pop()
|
||||
else:
|
||||
tx, ty = stack[-1]
|
||||
if m.group(3) is not None: # <text ...>...</text>
|
||||
attrs = m.group(2)
|
||||
x = float(re.search(r'\bx="([-\d.]+)"', attrs).group(1)) if re.search(r'\bx="', attrs) else 0.0
|
||||
y = float(re.search(r'\by="([-\d.]+)"', attrs).group(1)) if re.search(r'\by="', attrs) else 0.0
|
||||
yield "text", x + tx, y + ty, attrs, m.group(3)
|
||||
else: # <line .../> sau alt tag
|
||||
y = re.search(r'\by1="([-\d.]+)"', m.group(4) or "")
|
||||
if y:
|
||||
yield "line", float(y.group(1)) + ty, m.group(4)
|
||||
else:
|
||||
yield "other", 0.0, 0.0
|
||||
|
||||
|
||||
def texts(svg: str):
|
||||
for m in re.finditer(r'<text\b([^>]*)>(.*?)</text>', svg, re.S):
|
||||
attrs, inner = m.group(1), re.sub(r'<[^>]+>', '', m.group(2))
|
||||
x = float(re.search(r'\bx="([-\d.]+)"', attrs).group(1)) if re.search(r'\bx="', attrs) else 0.0
|
||||
y = float(re.search(r'\by="([-\d.]+)"', attrs).group(1)) if re.search(r'\by="', attrs) else 0.0
|
||||
for ev in _elements(svg):
|
||||
if ev[0] != "text":
|
||||
continue
|
||||
_, x, y, attrs, raw = ev
|
||||
inner = re.sub(r'<[^>]+>', '', raw)
|
||||
anchor = (re.search(r'text-anchor="(\w+)"', attrs) or [None, "start"])[1]
|
||||
w = len(inner) * CHAR_W
|
||||
x0 = x - w if anchor == "end" else (x - w / 2 if anchor == "middle" else x)
|
||||
yield x0, x0 + w, y, inner.strip()
|
||||
|
||||
|
||||
def levels(svg: str):
|
||||
"""Perechile line+text care joacă rolurile SL / intrare / TP, cu y absolut."""
|
||||
out = []
|
||||
ev = list(_elements(svg))
|
||||
for a, b in zip(ev, ev[1:]):
|
||||
if a[0] != "line" or b[0] != "text":
|
||||
continue
|
||||
label = re.sub(r'<[^>]+>', '', b[4]).strip()
|
||||
price = re.search(r'(\d+\.\d\d)', label)
|
||||
if not price:
|
||||
continue
|
||||
role = ("sl" if _ROLE.search(label)
|
||||
else "entry" if _ROLE_ENTRY.search(label)
|
||||
else "tp" if _ROLE_TP.search(label) else None)
|
||||
if role:
|
||||
out.append((float(price.group(1)), a[1], role))
|
||||
return out
|
||||
|
||||
|
||||
def _audit_one(i: int, svg: str) -> list[str]:
|
||||
problems = []
|
||||
vb = re.search(r'viewBox="0 0 ([\d.]+) ([\d.]+)"', svg)
|
||||
if not vb:
|
||||
return [f"svg {i}: fără viewBox"]
|
||||
vw, vh = float(vb.group(1)), float(vb.group(2))
|
||||
|
||||
if re.search(r"\n[ \t]*\n", svg):
|
||||
problems.append(f"svg {i}: linie goală înăuntru — markdown rupe blocul")
|
||||
|
||||
tx = list(texts(svg))
|
||||
for x0, x1, y, s in tx:
|
||||
if x1 > vw + 2:
|
||||
problems.append(f'svg {i}: text depășește marginea dreaptă ({x1:.0f} > {vw:.0f}): "{s[:45]}"')
|
||||
if y > vh + 2 or y < 0:
|
||||
problems.append(f'svg {i}: text în afara înălțimii (y={y:.0f}, viewBox {vh:.0f}): "{s[:45]}"')
|
||||
for a in range(len(tx)):
|
||||
for b in range(a + 1, len(tx)):
|
||||
xa0, xa1, ya, sa = tx[a]
|
||||
xb0, xb1, yb, sb = tx[b]
|
||||
if abs(ya - yb) < LINE_H and xa0 < xb1 - 4 and xb0 < xa1 - 4:
|
||||
problems.append(f'svg {i}: texte suprapuse — "{sa[:28]}" / "{sb[:28]}"')
|
||||
|
||||
# dacă diagrama are exact un triplet SL/intrare/TP, raportul distanțelor
|
||||
# în preț trebuie să fie cel din pixeli
|
||||
lv = levels(svg)
|
||||
if len(lv) == 3 and {r for _, _, r in lv} == {"sl", "entry", "tp"}:
|
||||
pts = sorted((p, y) for p, y, _ in lv)
|
||||
(plo, ylo), (pmid, ymid), (phi, yhi) = pts
|
||||
if pmid - plo > 0 and ylo - ymid > 0:
|
||||
rp = (phi - pmid) / (pmid - plo)
|
||||
rx = (ymid - yhi) / (ylo - ymid)
|
||||
if abs(rp - rx) > 0.05:
|
||||
problems.append(f"svg {i}: SCARĂ GREȘITĂ — raport în prețuri {rp:.2f}R, desenat {rx:.2f}R")
|
||||
return problems
|
||||
|
||||
|
||||
def check(path: Path) -> list[str]:
|
||||
md = path.read_text(encoding="utf-8")
|
||||
problems = []
|
||||
for i, svg in enumerate(re.findall(r'<svg .*?</svg>', md, re.S)):
|
||||
vb = re.search(r'viewBox="0 0 ([\d.]+) ([\d.]+)"', svg)
|
||||
if not vb:
|
||||
problems.append(f"svg {i}: fără viewBox")
|
||||
continue
|
||||
vw, vh = float(vb.group(1)), float(vb.group(2))
|
||||
|
||||
if re.search(r"\n[ \t]*\n", svg):
|
||||
problems.append(f"svg {i}: linie goală înăuntru — markdown rupe blocul")
|
||||
|
||||
tx = list(texts(svg))
|
||||
for x0, x1, y, s in tx:
|
||||
if x1 > vw + 2:
|
||||
problems.append(f'svg {i}: text depășește marginea dreaptă ({x1:.0f} > {vw:.0f}): "{s[:45]}"')
|
||||
if y > vh + 2 or y < 0:
|
||||
problems.append(f'svg {i}: text în afara înălțimii (y={y:.0f}, viewBox {vh:.0f}): "{s[:45]}"')
|
||||
for a in range(len(tx)):
|
||||
for b in range(a + 1, len(tx)):
|
||||
xa0, xa1, ya, sa = tx[a]
|
||||
xb0, xb1, yb, sb = tx[b]
|
||||
if abs(ya - yb) < LINE_H and xa0 < xb1 - 4 and xb0 < xa1 - 4:
|
||||
problems.append(f'svg {i}: texte suprapuse — "{sa[:28]}" / "{sb[:28]}"')
|
||||
|
||||
# dacă diagrama are exact 3 niveluri de preț orizontale (SL/intrare/TP),
|
||||
# raportul distanțelor în preț trebuie să fie cel din pixeli
|
||||
lv = re.findall(r'<line x1="[\d.]+" y1="([\d.]+)"[^>]*?/>\s*<text[^>]*>([^<]*?(\d+\.\d\d)[^<]*)</text>', svg)
|
||||
if len(lv) == 3:
|
||||
pts = sorted((float(p), float(y)) for y, _, p in lv)
|
||||
(plo, ylo), (pmid, ymid), (phi, yhi) = pts
|
||||
if pmid - plo > 0 and ylo - ymid > 0:
|
||||
rp = (phi - pmid) / (pmid - plo)
|
||||
rx = (ymid - yhi) / (ylo - ymid)
|
||||
if abs(rp - rx) > 0.05:
|
||||
problems.append(f"svg {i}: SCARĂ GREȘITĂ — raport în prețuri {rp:.2f}R, desenat {rx:.2f}R")
|
||||
problems += _audit_one(i, svg)
|
||||
return problems
|
||||
|
||||
|
||||
def selftest():
|
||||
def svg(body):
|
||||
return f'<svg viewBox="0 0 400 400">{body}</svg>'
|
||||
|
||||
# două panouri stivuite: fără translație etichetele ar părea suprapuse
|
||||
stacked = svg('<g transform="translate(0,0)"><text x="10" y="20">DA — panoul de sus</text></g>'
|
||||
'<g transform="translate(0,200)"><text x="10" y="20">NU — panoul de jos</text></g>')
|
||||
assert not any("suprapuse" in p for p in _audit_one(0, stacked)), "translația nu a fost aplicată"
|
||||
# translație imbricată
|
||||
nested = svg('<g transform="translate(0,100)"><g transform="translate(0,100)">'
|
||||
'<text x="10" y="20">jos</text></g></g>'
|
||||
'<g transform="translate(0,0)"><text x="10" y="20">sus</text></g>')
|
||||
assert not any("suprapuse" in p for p in _audit_one(0, nested)), "translația imbricată nu s-a acumulat"
|
||||
# suprapunerea reală din același panou rămâne semnalată
|
||||
real = svg('<g transform="translate(0,0)"><text x="10" y="20">unu</text>'
|
||||
'<text x="14" y="20">doi</text></g>')
|
||||
assert any("suprapuse" in p for p in _audit_one(0, real)), "suprapunerea reală a fost pierdută"
|
||||
# triplet SL/intrare/TP cu scară greșită rămâne semnalat
|
||||
bad = svg('<line x1="10" y1="300" x2="380" y2="300"/><text x="10" y="295">SL 90.00</text>'
|
||||
'<line x1="10" y1="250" x2="380" y2="250"/><text x="10" y="245">intrare 100.00</text>'
|
||||
'<line x1="10" y1="100" x2="380" y2="100"/><text x="10" y="95">TP 110.00</text>')
|
||||
assert any("SCARĂ" in p for p in _audit_one(0, bad)), "scara greșită nu a fost prinsă"
|
||||
# trei niveluri care NU sunt SL/intrare/TP nu mai declanșează SCARĂ
|
||||
notrip = svg('<line x1="10" y1="300" x2="380" y2="300"/><text x="10" y="295">minimul 99.80</text>'
|
||||
'<line x1="10" y1="250" x2="380" y2="250"/><text x="10" y="245">cel mai jos 99.90</text>'
|
||||
'<line x1="10" y1="100" x2="380" y2="100"/><text x="10" y="95">close-ul 100.60</text>')
|
||||
assert not any("SCARĂ" in p for p in _audit_one(0, notrip)), "niveluri care nu sunt triplet declanșează SCARĂ"
|
||||
print("selftest ok")
|
||||
|
||||
|
||||
def main():
|
||||
files = [Path(a) for a in sys.argv[1:]] or sorted(Path("summaries").glob("*/*.md"))
|
||||
if "--selftest" in sys.argv:
|
||||
selftest()
|
||||
return 0
|
||||
files = [Path(a) for a in sys.argv[1:] if a != "--selftest"] or sorted(Path("summaries").glob("*/*.md"))
|
||||
total = 0
|
||||
for f in files:
|
||||
probs = check(f)
|
||||
|
||||
Reference in New Issue
Block a user