"""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ă: - `` — niveluri, laturi de wedge, bracket-uri, cozi de săgeată; - `` — mediile mobile (punctul orb al primei versiuni); - `` — 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 ``, 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 `` 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 ``. ``-urile pot fi imbricate; ``-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' 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'', 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']*)>([^<]*)', 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 `` **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 `` 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): if m.group(0) == "": 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']*?)/?>', 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']*)>([^<]*)', 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 `…` de nivel întâi dintr-un .md sau .html.""" adanc, start = 0, None for m in re.finditer(r"", text): if m.group(0) == "": 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 = ('' '' '' 'pe linie' 'pe curba' 'pe candela' 'libera') 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 ------ # 1. etichetă și linie în ACELAȘI (imbricat), suprapuse: trebuie prinsă. # 2. etichetă și linie în -uri DIFERITE, care s-ar suprapune în # coordonate brute (ambele pe y≈50) dar nu în cele finale. mutat = ('' '' 'in panoul mutat' 'sus' '' '') 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 = ('' '' 'pe candela scalata') 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 = ('' '' '' '' '' 'intrare 577.00' 'stop 554.40' 'volum 1.2 mil.' 'liber 100.00' 'fara cifra aici') 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 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()