cifre_ascunse() ruleaza pe tot repo-ul, nu pe etichete numite, si raporteaza doar cazul care chiar costa: eticheta cu cifra peste un <rect> plin, fie de aceeasi culoare (distanta RGB sub 60, cu transparenta amestecata peste alb), fie desenat dupa ea si deci acoperind-o indiferent de culoare. Al doilea caz a fost adaugat pentru ca m2d_directie avea text gri sub corp verde - aceeasi paguba, dar filtrul pe culoare singur il rata. Calibrare care conta: CW=6.2 e potrivit pentru bold si prea larg pentru rest, iar coada goala prindea candela de alaturi. CW=5.4 pentru nebold a eliminat primele doua rezultate, verificate pe ecran ca false pozitive. Ancora inversa: pe versiunile dinainte de reparatiile de azi filtrul prinde exact cele doua defecte confirmate vizual. Scanarea: 17 etichete in 13 diagrame, din care 9 cu gaura pe cifra. Noua din cele 17 sunt in summaries/06_MODUL_5, unde nu s-a facut niciodata o trecere de etichete; in gen/strategii, dupa trei treceri, au ramas cinci. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ThN7ixZrowca8aTGrK1MUJ
402 lines
17 KiB
Python
402 lines
17 KiB
Python
"""O etichetă de diagramă nu are voie să stea peste desen.
|
||
|
||
`audit_diagrams.py` prinde doar text peste text. Astea sunt celelalte trei
|
||
feluri de suprapunere, găsite vizual pe 02.10.2026 și ușor de regresat la
|
||
următoarea mutare de etichetă:
|
||
|
||
- `<line>` — niveluri, laturi de wedge, bracket-uri, cozi de săgeată;
|
||
- `<polyline>` — mediile mobile (punctul orb al primei versiuni);
|
||
- `<rect>` — corpurile candelelor și barele de volum.
|
||
|
||
Verificarea e opt-in, pe etichete numite: pe un grafic de candele aproape
|
||
orice text atinge ceva, iar o regulă globală ar bloca tot repo-ul.
|
||
|
||
eticheta_libera(svg, "wedge_desc", {"baza = 4.00"}, rects=True)
|
||
|
||
Coordonatele se aplatizează înainte de comparație: la diagramele cu panouri
|
||
wrapper-ul e `<g transform="translate(0,h)">`, deci fără asta se comparau
|
||
coordonate din sisteme diferite și suprapunerile reale treceau nevăzute.
|
||
"""
|
||
import re
|
||
|
||
CW = 6.2 # lățimea unui caracter bold de 11-12px
|
||
WICK = "#1a1a1a" # fitilele: desenul le traversează oricum, prin construcție
|
||
|
||
|
||
# --- aplatizarea transformărilor -------------------------------------------
|
||
XA = ("x", "x1", "x2", "cx")
|
||
YA = ("y", "y1", "y2", "cy")
|
||
WA = ("width", "rx")
|
||
HA = ("height", "ry")
|
||
ID = (0.0, 0.0, 1.0, 1.0)
|
||
_ATTR = re.compile(r'([a-zA-Z][\w-]*)="(-?[\d.]+)"')
|
||
_TAG = re.compile(r'<(/?)([a-zA-Z]+)((?:"[^"]*"|[^>"])*)>')
|
||
|
||
|
||
def _ctm(attrs, base):
|
||
"""Transformarea cumulată: cea de pe `<g>` compusă peste cea moștenită."""
|
||
tx, ty, sx, sy = base
|
||
t = re.search(r'transform="([^"]*)"', attrs)
|
||
for fn, args in re.findall(r"(\w+)\(([^)]*)\)", t.group(1) if t else ""):
|
||
v = [float(a) for a in re.split(r"[,\s]+", args.strip()) if a]
|
||
if fn == "translate" and v:
|
||
tx, ty = tx + sx * v[0], ty + sy * (v[1] if len(v) > 1 else 0.0)
|
||
elif fn == "scale" and v:
|
||
sx, sy = sx * v[0], sy * (v[1] if len(v) > 1 else v[0])
|
||
# rotate/matrix/skew: ignorate, ca înainte — nu apar în repo, iar
|
||
# caseta unui text rotit n-ar fi oricum un dreptunghi drept.
|
||
return tx, ty, sx, sy
|
||
|
||
|
||
def _rescrie(attrs, ctm, tag):
|
||
tx, ty, sx, sy = ctm
|
||
|
||
def unu(m):
|
||
k, v = m.group(1), float(m.group(2))
|
||
if k in XA:
|
||
v = tx + sx * v
|
||
elif k in YA:
|
||
v = ty + sy * v
|
||
elif k in WA:
|
||
v = abs(sx) * v
|
||
elif k in HA:
|
||
v = abs(sy) * v
|
||
else:
|
||
return m.group(0)
|
||
return f'{k}="{v:.4f}"'
|
||
|
||
out = _ATTR.sub(unu, attrs)
|
||
out = re.sub(r'points="([^"]+)"', lambda m: 'points="%s"' % " ".join(
|
||
"{:.4f},{:.4f}".format(tx + sx * float(p.split(",")[0]),
|
||
ty + sy * float(p.split(",")[1]))
|
||
for p in m.group(1).split()), out)
|
||
if tag == "text" and sx != 1.0:
|
||
out += f' data-sx="{sx:g}"' # lățimea casetei se scalează cu el
|
||
return out
|
||
|
||
|
||
def _aplatizeaza(svg):
|
||
"""Rescrie coordonatele în sistemul rădăcinii, ținând cont de `<g transform>`.
|
||
|
||
`<g>`-urile pot fi imbricate; `<svg>`-urile imbricate rămân sisteme
|
||
separate, ca până acum (scanarea se face oricum panou cu panou).
|
||
"""
|
||
if "transform=" not in svg:
|
||
return svg
|
||
taieturi, stiva = [], [ID]
|
||
for m in _TAG.finditer(svg):
|
||
inchis, tag, attrs = m.group(1), m.group(2).lower(), m.group(3)
|
||
gol = attrs.rstrip().endswith("/")
|
||
if tag == "g":
|
||
if inchis:
|
||
if len(stiva) > 1:
|
||
stiva.pop()
|
||
elif not gol:
|
||
stiva.append(_ctm(attrs, stiva[-1]))
|
||
continue
|
||
if inchis or stiva[-1] == ID:
|
||
continue
|
||
taieturi.append((m.start(3), m.end(3), _rescrie(attrs, stiva[-1], tag)))
|
||
for a, b, nou in reversed(taieturi):
|
||
svg = svg[:a] + nou + svg[b:]
|
||
return svg
|
||
|
||
|
||
def _bbox(m, min_px):
|
||
"""Caseta etichetei, lărgită cu marginea cerută."""
|
||
tx, ty, attrs, s = float(m.group(1)), float(m.group(2)), m.group(3), m.group(4)
|
||
k = float(re.search(r'data-sx="([\d.]+)"', attrs).group(1)) if "data-sx=" in attrs else 1.0
|
||
w = CW * len(s) * k
|
||
# fara text-anchor, SVG aliniaza la stanga (start), nu centrat. Presupunerea
|
||
# ca e centrat dadea alarme false pe etichetele de Level cu label_at="left",
|
||
# care se deseneaza fara anchor.
|
||
x0 = tx - w if 'anchor="end"' in attrs else tx if 'anchor="middle"' not in attrs else tx - w / 2
|
||
return s, (x0 - min_px, x0 + w + min_px, ty - 9 * k - min_px, ty + 3 * k + min_px)
|
||
|
||
|
||
def _segmente(svg):
|
||
"""Toate segmentele desenate, ca perechi de capete: linii + polilinii."""
|
||
for m in re.finditer(r'<line x1="(-?[\d.]+)" y1="(-?[\d.]+)" x2="(-?[\d.]+)"'
|
||
r' y2="(-?[\d.]+)"[^/]*?stroke="(#[0-9a-f]{3,6})"', svg):
|
||
if m.group(5) != WICK:
|
||
yield ("linia", *map(float, m.groups()[:4]))
|
||
for m in re.finditer(r'<polyline points="([^"]+)"', svg):
|
||
pts = [tuple(map(float, p.split(","))) for p in m.group(1).split()]
|
||
for (ax, ay), (bx, by) in zip(pts, pts[1:]):
|
||
yield ("curba", ax, ay, bx, by)
|
||
|
||
|
||
def _atinge(box, ax, ay, bx, by):
|
||
x0, x1, top, bot = box
|
||
if max(ax, bx) < x0 or min(ax, bx) > x1:
|
||
return False
|
||
if ax == bx:
|
||
ys = (ay, by)
|
||
else: # y-ul segmentului pe porțiunea comună
|
||
ys = tuple(ay + (by - ay) * (min(max(x, min(ax, bx)), max(ax, bx)) - ax) / (bx - ax)
|
||
for x in (x0, x1))
|
||
return min(ys) <= bot and max(ys) >= top
|
||
|
||
|
||
def eticheta_libera(svg, name, etichete, min_px=5.0, rects=False, segmente=True):
|
||
"""Ridică AssertionError dacă vreuna din `etichete` stă peste desen.
|
||
|
||
`segmente=False` pentru etichetele de nivel: alea stau lipite de linia lor
|
||
prin construcție (y − 6), deci doar candelele sunt o suprapunere reală.
|
||
"""
|
||
svg = _aplatizeaza(svg)
|
||
segs = list(_segmente(svg)) if segmente else []
|
||
boxes = []
|
||
if rects:
|
||
for m in re.finditer(r'<rect x="(-?[\d.]+)" y="(-?[\d.]+)" width="([\d.]+)"'
|
||
r' height="([\d.]+)"([^/]*)/>', svg):
|
||
x, y, w, h = map(float, m.groups()[:4])
|
||
if "fill-opacity" in m.group(5): # zonele colorate stau în fundal
|
||
continue
|
||
boxes.append((x, y, x + w, y + h))
|
||
for m in re.finditer(r'<text x="(-?[\d.]+)" y="(-?[\d.]+)"([^>]*)>([^<]*)</text>', svg):
|
||
if m.group(4) not in etichete:
|
||
continue
|
||
s, box = _bbox(m, min_px)
|
||
for kind, ax, ay, bx, by in segs:
|
||
if _atinge(box, ax, ay, bx, by):
|
||
raise AssertionError(f"{name}: eticheta '{s}' sta pe {kind} "
|
||
f"({ax:.0f},{ay:.0f})-({bx:.0f},{by:.0f})")
|
||
for rx0, ry0, rx1, ry1 in boxes:
|
||
if rx0 < box[1] and rx1 > box[0] and ry0 < box[3] and ry1 > box[2]:
|
||
raise AssertionError(f"{name}: eticheta '{s}' sta pe dreptunghiul "
|
||
f"({rx0:.0f},{ry0:.0f})-({rx1:.0f},{ry1:.0f})")
|
||
|
||
|
||
# ===========================================================================
|
||
# Filtrul orb: o cifră scrisă peste un corp plin de aceeași culoare
|
||
#
|
||
# Trierea vizuală din 02.10.2026 (02, 04, A3, 13 diagrame citite pe ecran) a
|
||
# dat o singură cauză fizică pentru suprapunerile care chiar schimbă ce
|
||
# citești: textul stă peste un `<rect>` **de culoarea lui**, iar caracterele
|
||
# nu se încalecă — dispar („intrare 577.00" se citea „intrare 577. 0").
|
||
# Celelalte ~91 de atingeri — text peste fitil, peste linia unui nivel, peste
|
||
# chenar, peste coada unei săgeți — rămân lizibile: fundalul e o linie
|
||
# subțire, nu o suprafață plină.
|
||
#
|
||
# De aceea filtrul ăsta NU e opt-in pe etichete numite ca `eticheta_libera`:
|
||
# fiind îngust, poate rula orb pe tot repo-ul.
|
||
#
|
||
# Ordinea de desenare nu contează: verde pe verde e invizibil și dacă textul
|
||
# e pictat deasupra. De aceea singurul criteriu e culoarea, nu ordinea.
|
||
|
||
DIST = 60.0 # distanță RGB sub care două culori nu se mai disting
|
||
ACOPERIRE = 4.0 # px de suprapunere pe orizontală sub care e doar atingere
|
||
CW_N = 5.4 # un caracter de 12px NEbold e mai îngust decât CW
|
||
# `_bbox` folosește CW=6.2 peste tot, bun pentru etichetele bold și prudent
|
||
# pentru restul. Aici prudența costă: la o etichetă lungă și nebold, cei ~0.8
|
||
# px pe caracter se adună într-o coadă goală de 30 px, care prinde candela de
|
||
# alături și raportează o înghițire care nu există (`buy_doji_labu`).
|
||
|
||
|
||
def _rgb(c, alpha=1.0):
|
||
"""#rgb/#rrggbb -> (r, g, b), amestecat peste alb dacă e transparent."""
|
||
c = c.lstrip("#")
|
||
if len(c) == 3:
|
||
c = "".join(ch * 2 for ch in c)
|
||
v = tuple(int(c[i:i + 2], 16) for i in (0, 2, 4))
|
||
return tuple(255 - (255 - x) * alpha for x in v)
|
||
|
||
|
||
def _departe(a, b):
|
||
return sum((x - y) ** 2 for x, y in zip(a, b)) ** 0.5
|
||
|
||
|
||
def _panouri(svg):
|
||
"""Fiecare `<svg>` imbricat e un sistem de coordonate separat.
|
||
|
||
Întoarce bucățile care se pot compara între ele: panoul exterior fără
|
||
imbricate, apoi fiecare imbricat, recursiv.
|
||
"""
|
||
out, adanc, start = [], 0, None
|
||
rest = []
|
||
i = 0
|
||
for m in re.finditer(r"<svg\b|</svg>", svg):
|
||
if m.group(0) == "</svg>":
|
||
adanc -= 1
|
||
if adanc == 1 and start is not None:
|
||
out.extend(_panouri(svg[start:m.end()]))
|
||
i = m.end()
|
||
start = None
|
||
else:
|
||
adanc += 1
|
||
if adanc == 2:
|
||
rest.append(svg[i:m.start()])
|
||
start = m.start()
|
||
rest.append(svg[i:])
|
||
return ["".join(rest)] + out
|
||
|
||
|
||
def cifre_ascunse(svg, nume=""):
|
||
"""Etichetele cu cifre care stau peste un dreptunghi de culoarea lor.
|
||
|
||
Întoarce o listă de dicte (fără să ridice nimic): `eticheta`, `culoare`,
|
||
`peste` (culoarea efectivă a dreptunghiului) și `px` — cât din lățimea
|
||
etichetei e înghițit.
|
||
"""
|
||
gasite = []
|
||
for panou in _panouri(svg):
|
||
panou = _aplatizeaza(panou)
|
||
rects = []
|
||
for m in re.finditer(r'<rect ([^>]*?)/?>', panou):
|
||
a = dict(_ATTR.findall(m.group(1)))
|
||
fill = re.search(r'fill="(#[0-9a-fA-F]{3,6})"', m.group(1))
|
||
if not fill or "width" not in a or "height" not in a:
|
||
continue
|
||
op = float(a.get("opacity", 1.0)) * float(a.get("fill-opacity", 1.0))
|
||
x, y = float(a["x"]), float(a["y"])
|
||
rects.append((x, y, x + float(a["width"]), y + float(a["height"]),
|
||
_rgb(fill.group(1), op), op, m.start()))
|
||
for m in re.finditer(r'<text x="(-?[\d.]+)" y="(-?[\d.]+)"([^>]*)>([^<]*)</text>',
|
||
panou):
|
||
s = m.group(4)
|
||
if not re.search(r"\d", s):
|
||
continue
|
||
culoare = re.search(r'fill="(#[0-9a-fA-F]{3,6})"', m.group(3))
|
||
if not culoare:
|
||
continue
|
||
c = _rgb(culoare.group(1))
|
||
_, (x0, x1, top, bot) = _bbox(m, 0.0)
|
||
if "bold" not in m.group(3): # caseta reală, nu cea prudentă
|
||
w = (x1 - x0) * CW_N / CW
|
||
x0, x1 = ((x1 - w, x1) if 'anchor="end"' in m.group(3) else
|
||
((x0 + x1 - w) / 2, (x0 + x1 + w) / 2)
|
||
if 'anchor="middle"' in m.group(3) else (x0, x0 + w))
|
||
for rx0, ry0, rx1, ry1, rc, op, poz in rects:
|
||
lat = min(x1, rx1) - max(x0, rx0)
|
||
inalt = min(bot, ry1) - max(top, ry0)
|
||
if lat < ACOPERIRE or inalt < 4.0:
|
||
continue
|
||
if _departe(c, rc) <= DIST:
|
||
de_ce = "aceeași culoare"
|
||
elif poz > m.start() and op >= 0.5:
|
||
# desenat PESTE text: îl acoperă oricare i-ar fi culoarea
|
||
# (`m2d_directie`: corp verde peste „…mișcarea 99.80" gri)
|
||
de_ce = "desenat peste"
|
||
else:
|
||
continue
|
||
# ce anume dispare: lățimea e aproximată uniform, deci
|
||
# bucata e orientativă — spune însă dacă gaura cade pe cifră
|
||
lat_car = (x1 - x0) / max(len(s), 1)
|
||
i0 = int((max(x0, rx0) - x0) / lat_car)
|
||
i1 = min(len(s), int((min(x1, rx1) - x0) / lat_car) + 1)
|
||
gasite.append({"diagrama": nume, "eticheta": s, "de_ce": de_ce,
|
||
"culoare": culoare.group(1), "px": round(lat),
|
||
"inghitit": s[i0:i1],
|
||
"peste": tuple(round(v) for v in rc)})
|
||
break
|
||
return gasite
|
||
|
||
|
||
def blocuri_svg(text):
|
||
"""Blocurile `<svg>…</svg>` de nivel întâi dintr-un .md sau .html."""
|
||
adanc, start = 0, None
|
||
for m in re.finditer(r"<svg\b|</svg>", text):
|
||
if m.group(0) == "</svg>":
|
||
adanc -= 1
|
||
if adanc == 0 and start is not None:
|
||
yield text[start:m.end()]
|
||
start = None
|
||
else:
|
||
if adanc == 0:
|
||
start = m.start()
|
||
adanc += 1
|
||
|
||
|
||
def demo():
|
||
svg = ('<svg><line x1="0" y1="50" x2="100" y2="50" stroke="#c00"/>'
|
||
'<polyline points="0,200 100,120"/>'
|
||
'<rect x="0" y="300" width="20" height="40" fill="#188a3e"/>'
|
||
'<text x="10" y="55">pe linie</text>'
|
||
'<text x="90" y="128">pe curba</text>'
|
||
'<text x="10" y="310">pe candela</text>'
|
||
'<text x="10" y="400">libera</text></svg>')
|
||
for et in ("pe linie", "pe curba", "pe candela"):
|
||
try:
|
||
eticheta_libera(svg, "demo", {et}, rects=True)
|
||
except AssertionError:
|
||
pass
|
||
else:
|
||
raise AssertionError(f"'{et}' trebuia prinsa")
|
||
eticheta_libera(svg, "demo", {"libera"}, rects=True)
|
||
|
||
# --- transformări: un panou mutat cu <g transform="translate(...)"> ------
|
||
# 1. etichetă și linie în ACELAȘI <g> (imbricat), suprapuse: trebuie prinsă.
|
||
# 2. etichetă și linie în <g>-uri DIFERITE, care s-ar suprapune în
|
||
# coordonate brute (ambele pe y≈50) dar nu în cele finale.
|
||
mutat = ('<svg><g transform="translate(10,0)"><g transform="translate(0,300)">'
|
||
'<line x1="0" y1="50" x2="100" y2="50" stroke="#c00"/>'
|
||
'<text x="10" y="55">in panoul mutat</text></g></g>'
|
||
'<g transform="translate(0,0)"><text x="10" y="55">sus</text></g>'
|
||
'<g transform="translate(0,200)">'
|
||
'<line x1="0" y1="50" x2="100" y2="50" stroke="#c00"/></g></svg>')
|
||
try:
|
||
eticheta_libera(mutat, "demo", {"in panoul mutat"})
|
||
except AssertionError:
|
||
pass
|
||
else:
|
||
raise AssertionError("'in panoul mutat' trebuia prinsa (translate ignorat?)")
|
||
eticheta_libera(mutat, "demo", {"sus"}) # 250px mai jos decat linia ei
|
||
|
||
# scale: o candela de 20x40 scalata x2 ajunge sub eticheta de la y=620
|
||
scalat = ('<svg><g transform="scale(2)">'
|
||
'<rect x="0" y="300" width="20" height="40" fill="#188a3e"/>'
|
||
'<text x="5" y="310">pe candela scalata</text></g></svg>')
|
||
try:
|
||
eticheta_libera(scalat, "demo", {"pe candela scalata"}, rects=True)
|
||
except AssertionError:
|
||
pass
|
||
else:
|
||
raise AssertionError("'pe candela scalata' trebuia prinsa")
|
||
print("eticheta_libera: ok")
|
||
|
||
# --- filtrul orb: cifră peste un corp de aceeași culoare ---------------
|
||
# cazul real (mp2_exemplu, 02.10.2026): verde pe verde, caracterul dispare
|
||
verde = ('<svg><rect x="40" y="90" width="9" height="30" fill="#188a3e"/>'
|
||
'<line x1="0" y1="100" x2="200" y2="100" stroke="#188a3e"/>'
|
||
'<polyline points="0,100 200,100" stroke="#188a3e"/>'
|
||
'<rect x="40" y="300" width="9" height="30" fill="#188a3e" opacity="0.55"/>'
|
||
'<rect x="140" y="90" width="9" height="30" fill="#c00"/>'
|
||
'<text x="10" y="100" fill="#188a3e">intrare 577.00</text>'
|
||
'<text x="120" y="100" fill="#188a3e">stop 554.40</text>'
|
||
'<text x="10" y="310" fill="#188a3e">volum 1.2 mil.</text>'
|
||
'<text x="10" y="400" fill="#188a3e">liber 100.00</text>'
|
||
'<text x="10" y="500" fill="#188a3e">fara cifra aici</text></svg>')
|
||
prinse = {g["eticheta"] for g in cifre_ascunse(verde)}
|
||
assert prinse == {"intrare 577.00"}, prinse
|
||
# dovada pe rând: linia și curba de aceeași culoare NU se aprind (textul
|
||
# rămâne lizibil peste un fir de 1 px), corpul roșu de sub text nici el
|
||
# (altă culoare = contrast), bara de volum la 0.55 nici ea (e pală), iar
|
||
# eticheta fără cifră nu intră în filtru deloc.
|
||
print("cifre_ascunse: ok")
|
||
|
||
|
||
def scaneaza(cai):
|
||
"""Raport pe fișiere: .svg întregi, sau blocurile <svg> dintr-un .md/.html."""
|
||
from pathlib import Path
|
||
tot = []
|
||
for cale in cai:
|
||
text = Path(cale).read_text(encoding="utf-8")
|
||
bucati = [text] if str(cale).endswith(".svg") else list(blocuri_svg(text))
|
||
for k, b in enumerate(bucati):
|
||
nume = Path(cale).name + (f" #{k + 1}" if len(bucati) > 1 else "")
|
||
tot += cifre_ascunse(b, nume)
|
||
for g in tot:
|
||
print(f"{g['diagrama']}: '{g['eticheta']}' — dispare ~'{g['inghitit']}' "
|
||
f"({g['px']} px, {g['de_ce']})")
|
||
print(f"{len(tot)} etichete cu cifre înghițite de un corp plin")
|
||
return tot
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
if len(sys.argv) > 1:
|
||
scaneaza(sys.argv[1:])
|
||
else:
|
||
demo()
|