Files
atm-curs-dl/gen/eticheta_libera.py
Claude Agent d82e1d5ce5 eticheta_libera: filtru orb pentru cifrele care dispar sub candele
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
2026-10-02 06:07:34 +00:00

402 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()