Files
Fundamentos-de-la-criptografia/Lab0_cifrados_clasicos.py
2026-09-28 19:56:21 -03:00

600 lines
26 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.
#!/usr/bin/env python3
"""
cifrados_clasicos.py — Tres Cifrados Clásicos en Tabs
Herramienta pedagógica · Fundamentos de Criptografía
Requiere Python 3.x (tkinter incluido en stdlib)
Ejecutar: python cifrados_clasicos.py
"""
import tkinter as tk
from tkinter import ttk
import string, math, random
ALPHA = string.ascii_uppercase
N = 26
# Frecuencias del español (%)
FREQ_ES = {
'A':12.53,'B':1.42,'C':4.68,'D':5.86,'E':13.68,'F':0.69,'G':1.01,
'H':0.70,'I':6.25,'J':0.44,'K':0.02,'L':4.97,'M':3.15,'N':6.71,
'O':8.68,'P':2.51,'Q':0.88,'R':6.87,'S':7.98,'T':4.63,'U':3.93,
'V':0.90,'W':0.01,'X':0.22,'Y':0.90,'Z':0.52,
}
P = dict(
bg='#0d0d1a', panel='#13132b', panel2='#1a1a35',
border='#2a2a50', text='#dde1f0', muted='#555580',
accent='#818cf8', ac='#38bdf8', bc='#fb923c',
rc='#4ade80', btn='#4f46e5', hl='#fde68a',
)
def mk_btn(parent, label, color, cmd, **kw):
return tk.Button(parent, text=label, font=('Consolas', 9, 'bold'),
bg=color, fg='white', relief=tk.FLAT,
padx=10, pady=5, cursor='hand2', command=cmd, **kw)
class CifradosClasicos:
def __init__(self, root):
self.root = root
root.title('Cifrados Clásicos — Fundamentos de Criptografía')
root.configure(bg=P['bg'])
root.geometry('1080x760')
root.minsize(860, 620)
self.shift_var = tk.IntVar(value=3)
self.cmode_var = tk.StringVar(value='E')
self.vig_plain = tk.StringVar()
self.vig_key = tk.StringVar(value='CLAVE')
self.vig_out = tk.StringVar()
self.sub_mode = tk.StringVar(value='E')
self.sub_key = list(ALPHA)
random.shuffle(self.sub_key)
# caches
self._vig_p = self._vig_k = self._vig_c = []
self._sub_result = ''
self._sub_mapping = {}
self._build()
# ── Scaffold ──────────────────────────────────────────────────────────────
def _build(self):
bar = tk.Frame(self.root, bg=P['panel'], pady=10)
bar.pack(fill=tk.X)
tk.Label(bar, text='🔐 Cifrados Clásicos',
font=('Consolas', 15, 'bold'),
bg=P['panel'], fg=P['accent']).pack(side=tk.LEFT, padx=18)
tk.Label(bar,
text='César · Vigenère · Sustitución + Análisis de Frecuencia',
font=('Consolas', 9), bg=P['panel'], fg=P['muted']
).pack(side=tk.RIGHT, padx=18)
st = ttk.Style()
st.theme_use('default')
st.configure('C.TNotebook', background=P['bg'], borderwidth=0)
st.configure('C.TNotebook.Tab',
background=P['panel2'], foreground=P['muted'],
padding=[22, 8], font=('Consolas', 10, 'bold'))
st.map('C.TNotebook.Tab',
background=[('selected', P['btn'])],
foreground=[('selected', 'white')])
nb = ttk.Notebook(self.root, style='C.TNotebook')
nb.pack(fill=tk.BOTH, expand=True, padx=12, pady=10)
for tab_text, fn in [
(' César / ROT-N ', self._build_caesar),
(' Vigenère ', self._build_vigenere),
(' Sustitución + Frecuencias ', self._build_substitution),
]:
f = tk.Frame(nb, bg=P['bg'])
nb.add(f, text=tab_text)
fn(f)
# ══════════════════════════════════════════════════════════════════════════
# TAB 1 · CÉSAR / ROT-N
# ══════════════════════════════════════════════════════════════════════════
def _build_caesar(self, parent):
left = tk.Frame(parent, bg=P['bg'], padx=16, pady=12)
left.pack(side=tk.LEFT, fill=tk.Y)
right = tk.Frame(parent, bg=P['bg'], pady=12)
right.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# Slider desplazamiento
tk.Label(left, text='Desplazamiento N', font=('Consolas', 10, 'bold'),
bg=P['bg'], fg=P['accent']).pack(anchor=tk.W)
srow = tk.Frame(left, bg=P['bg'])
srow.pack(fill=tk.X, pady=(4, 12))
tk.Scale(srow, from_=0, to=25, orient=tk.HORIZONTAL,
variable=self.shift_var, command=self._on_shift,
bg=P['panel2'], fg=P['text'], highlightthickness=0,
troughcolor=P['border'], length=230,
font=('Consolas', 9)).pack(side=tk.LEFT)
self.c_shift_lbl = tk.Label(srow, text='N = 3',
font=('Consolas', 13, 'bold'),
bg=P['bg'], fg=P['hl'], padx=8)
self.c_shift_lbl.pack(side=tk.LEFT)
# Modo
mrow = tk.Frame(left, bg=P['bg'])
mrow.pack(fill=tk.X, pady=(0, 12))
for txt, val, col in [('Cifrar', 'E', P['ac']), ('Descifrar', 'D', P['bc'])]:
tk.Radiobutton(mrow, text=txt, variable=self.cmode_var, value=val,
command=self._update_caesar,
bg=P['bg'], fg=col, selectcolor=P['panel2'],
activebackground=P['bg'],
font=('Consolas', 10, 'bold')
).pack(side=tk.LEFT, padx=(0, 18))
# Input / output
for attr, lbl_txt, fg, editable in [
('c_in', 'Texto de entrada', P['ac'], True),
('c_out', 'Resultado', P['rc'], False),
]:
tk.Label(left, text=lbl_txt, font=('Consolas', 9),
bg=P['bg'], fg=P['muted']).pack(anchor=tk.W)
w = tk.Text(left, height=4, width=33,
font=('Consolas', 11), wrap=tk.WORD,
bg=P['panel2'] if editable else P['panel'],
fg=fg, relief=tk.FLAT, insertbackground=fg,
state=tk.NORMAL if editable else tk.DISABLED)
w.pack(pady=(2, 10))
setattr(self, attr, w)
self.c_in.bind('<KeyRelease>', lambda e: self._update_caesar())
self.c_in.insert('1.0', 'Hola Mundo')
self.c_formula = tk.Label(left, text='', font=('Consolas', 9),
bg=P['bg'], fg=P['muted'], justify=tk.LEFT)
self.c_formula.pack(anchor=tk.W)
# Rueda
tk.Label(right, text='Rueda de César',
font=('Consolas', 10, 'bold'),
bg=P['bg'], fg=P['accent']).pack()
self.c_cv = tk.Canvas(right, width=430, height=430,
bg=P['bg'], highlightthickness=0)
self.c_cv.pack(pady=4)
self._update_caesar()
def _on_shift(self, val=None):
self.c_shift_lbl.config(text=f'N = {self.shift_var.get()}')
self._update_caesar()
def _draw_wheel(self, hl_plain=None, hl_cipher=None):
cv = self.c_cv
cv.delete('all')
shift = self.shift_var.get()
cx = cy = 215
R1, R2 = 190, 145 # outer / inner ring radius
T1, T2 = 172, 126 # text radii
# Rings
cv.create_oval(cx-R1, cy-R1, cx+R1, cy+R1,
fill=P['panel2'], outline=P['border'], width=2)
cv.create_oval(cx-R2, cy-R2, cx+R2, cy+R2,
fill=P['panel'], outline=P['accent'], width=2)
cv.create_oval(cx-105, cy-105, cx+105, cy+105,
fill=P['panel2'], outline=P['border'], width=1)
# Segment dividers
for i in range(N):
a = math.radians(-90 + i * 360/N)
xa = cx + R2 * math.cos(a); ya = cy + R2 * math.sin(a)
xb = cx + R1 * math.cos(a); yb = cy + R1 * math.sin(a)
cv.create_line(xa, ya, xb, yb, fill=P['border'], width=1)
for i in range(N):
angle = math.radians(-90 + i * 360/N)
# Outer: texto plano (fijo)
plain_letter = ALPHA[i]
xo = cx + T1 * math.cos(angle)
yo = cy + T1 * math.sin(angle)
is_hl = (hl_plain and plain_letter == hl_plain)
if is_hl:
cv.create_oval(xo-14, yo-14, xo+14, yo+14,
fill='#2d2200', outline=P['hl'], width=2)
cv.create_text(xo, yo, text=plain_letter,
font=('Consolas', 11, 'bold'),
fill=P['hl'] if is_hl else P['text'])
# Inner: cifrado (girado por shift)
cipher_letter = ALPHA[(i + shift) % N]
xi = cx + T2 * math.cos(angle)
yi = cy + T2 * math.sin(angle)
is_hl2 = (hl_cipher and cipher_letter == hl_cipher)
if is_hl2:
cv.create_oval(xi-14, yi-14, xi+14, yi+14,
fill='#0a2510', outline=P['rc'], width=2)
cv.create_text(xi, yi, text=cipher_letter,
font=('Consolas', 11, 'bold'),
fill=P['rc'] if is_hl2 else P['bc'])
# Centro
cv.create_text(cx, cy - 10, text=f'N = {shift}',
font=('Consolas', 14, 'bold'), fill=P['accent'])
cv.create_text(cx, cy + 12, text='C = (P+N) mod 26',
font=('Consolas', 8), fill=P['muted'])
# Flecha indicadora en la cima
tip_x = cx + (R1 + 8) * math.cos(math.radians(-90))
tip_y = cy + (R1 + 8) * math.sin(math.radians(-90))
cv.create_polygon(tip_x - 7, tip_y, tip_x + 7, tip_y, tip_x, tip_y - 18,
fill=P['hl'])
# Etiquetas de anillos
cv.create_text(cx, cy - T1 - 22, text='Texto plano (fijo)',
font=('Consolas', 8), fill=P['ac'])
cv.create_text(cx, cy - T2 + 26, text='Cifrado (gira)',
font=('Consolas', 8), fill=P['bc'])
def _update_caesar(self, *_):
text = self.c_in.get('1.0', tk.END).strip().upper()
shift = self.shift_var.get()
sgn = 1 if self.cmode_var.get() == 'E' else -1
result = ''
for ch in text:
result += ALPHA[(ALPHA.index(ch) + sgn * shift) % N] if ch in ALPHA else ch
self.c_out.config(state=tk.NORMAL)
self.c_out.delete('1.0', tk.END)
self.c_out.insert('1.0', result)
self.c_out.config(state=tk.DISABLED)
first_p = next((c for c in text if c in ALPHA), None)
first_c = ALPHA[(ALPHA.index(first_p) + sgn * shift) % N] if first_p else None
if self.cmode_var.get() == 'E':
self.c_formula.config(
text=f'Fórmula: C = (P + {shift}) mod 26\n'
f'Ejemplo: A(0) → {ALPHA[shift % N]}({shift})')
else:
self.c_formula.config(
text=f'Fórmula: P = (C − {shift}) mod 26\n'
f'Ejemplo: {ALPHA[shift % N]}({shift}) → A(0)')
self._draw_wheel(hl_plain=first_p, hl_cipher=first_c)
# ══════════════════════════════════════════════════════════════════════════
# TAB 2 · VIGENÈRE
# ══════════════════════════════════════════════════════════════════════════
def _build_vigenere(self, parent):
top = tk.Frame(parent, bg=P['bg'], padx=14, pady=10)
top.pack(fill=tk.X)
# IO
io = tk.Frame(top, bg=P['bg'])
io.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 18))
for attr, lbl_txt, var, fg, editable in [
('v_plain_e', 'Texto plano', self.vig_plain, P['ac'], True),
('v_key_e', 'Clave', self.vig_key, P['bc'], True),
('v_out_e', 'Cifrado', self.vig_out, P['rc'], False),
]:
tk.Label(io, text=lbl_txt, font=('Consolas', 9),
bg=P['bg'], fg=P['muted']).pack(anchor=tk.W)
e = tk.Entry(io, textvariable=var,
font=('Consolas', 12, 'bold'), relief=tk.FLAT,
bg=P['panel2'] if editable else P['panel'],
fg=fg, insertbackground=fg, width=26,
state=tk.NORMAL if editable else tk.DISABLED)
e.pack(pady=(2, 8), fill=tk.X)
setattr(self, attr, e)
self.vig_plain.trace_add('write', self._update_vig)
self.vig_key.trace_add('write', self._update_vig)
self.vig_plain.set('HOLA')
self.v_explain = tk.Label(io, text='', font=('Consolas', 8),
bg=P['bg'], fg=P['muted'],
justify=tk.LEFT, wraplength=340)
self.v_explain.pack(anchor=tk.W, pady=(6, 0))
# Mapa char a char
map_f = tk.Frame(top, bg=P['bg'])
map_f.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
tk.Label(map_f, text='Mapeo carácter a carácter',
font=('Consolas', 9, 'bold'),
bg=P['bg'], fg=P['accent']).pack(anchor=tk.W)
self.v_map_cv = tk.Canvas(map_f, height=100, bg=P['panel2'],
highlightthickness=0)
self.v_map_cv.pack(fill=tk.X, pady=(4, 0))
self.v_map_cv.bind('<Configure>', lambda e: self._draw_vig_map())
# Tabla Vigenère (scrollable)
tk.Label(parent,
text='Tabla de Vigenère (fila=clave, columna=plano, celda=cifrado)',
font=('Consolas', 8, 'bold'), bg=P['bg'], fg=P['muted']
).pack(padx=14, anchor=tk.W)
tf = tk.Frame(parent, bg=P['bg'])
tf.pack(fill=tk.BOTH, expand=True, padx=14, pady=(2, 10))
self.v_tbl = tk.Canvas(tf, bg=P['panel2'], highlightthickness=0)
vb = ttk.Scrollbar(tf, orient=tk.VERTICAL, command=self.v_tbl.yview)
hb = ttk.Scrollbar(tf, orient=tk.HORIZONTAL, command=self.v_tbl.xview)
self.v_tbl.configure(yscrollcommand=vb.set, xscrollcommand=hb.set)
hb.pack(side=tk.BOTTOM, fill=tk.X)
vb.pack(side=tk.RIGHT, fill=tk.Y)
self.v_tbl.pack(fill=tk.BOTH, expand=True)
self._draw_vig_table()
def _draw_vig_table(self, hr=-1, hc=-1):
cv = self.v_tbl
cv.delete('all')
CS = 20
for r in range(27):
for c in range(27):
x, y = c * CS, r * CS
if r == 0 and c == 0:
txt, bg, fg = '', P['panel'], P['muted']
elif r == 0:
li = c - 1; hl = (li == hc)
txt = ALPHA[li]
bg = P['ac'] if hl else P['panel']
fg = P['bg'] if hl else P['ac']
elif c == 0:
li = r - 1; hl = (li == hr)
txt = ALPHA[li]
bg = P['bc'] if hl else P['panel']
fg = P['bg'] if hl else P['bc']
else:
letter = ALPHA[(r - 1 + c - 1) % N]
active = (r-1 == hr and c-1 == hc)
in_r = (r-1 == hr)
in_c = (c-1 == hc)
txt = letter
if active:
bg, fg = P['rc'], P['bg']
elif in_r:
bg, fg = '#162210', P['rc']
elif in_c:
bg, fg = '#101630', P['ac']
else:
bg, fg = P['panel2'], P['muted']
cv.create_rectangle(x, y, x+CS, y+CS,
fill=bg, outline=P['bg'], width=1)
cv.create_text(x+CS//2, y+CS//2, text=txt,
font=('Consolas', 8, 'bold'
if r == 0 or c == 0 or (r-1==hr and c-1==hc)
else 'normal'),
fill=fg)
total = 27 * CS
cv.configure(scrollregion=(0, 0, total, total))
def _update_vig(self, *_):
plain = ''.join(c for c in self.vig_plain.get().upper() if c in ALPHA)
key = ''.join(c for c in self.vig_key.get().upper() if c in ALPHA)
self.v_out_e.config(state=tk.NORMAL)
if not plain or not key:
self.vig_out.set('')
self.v_out_e.config(state=tk.DISABLED)
self._draw_vig_table()
return
cipher = [ALPHA[(ALPHA.index(p) + ALPHA.index(key[i % len(key)])) % N]
for i, p in enumerate(plain)]
self.vig_out.set(''.join(cipher))
self.v_out_e.config(state=tk.DISABLED)
p0 = ALPHA.index(plain[0]); k0 = ALPHA.index(key[0]); c0 = (p0+k0) % N
self._draw_vig_table(hr=k0, hc=p0)
self.v_explain.config(
text=(f"'{plain[0]}' (col {p0}) + clave '{key[0]}' (fila {k0})\n"
f" → ({p0}+{k0}) mod 26 = {c0} → '{ALPHA[c0]}'"))
self._vig_p = list(plain)
self._vig_k = list(key)
self._vig_c = cipher
self._draw_vig_map()
def _draw_vig_map(self):
cv = self.v_map_cv
cv.delete('all')
pl = self._vig_p; ky = self._vig_k; ci = self._vig_c
if not pl: return
W = cv.winfo_width() or 500
n = min(len(pl), max(1, (W - 72) // 30))
cell = max(26, (W - 72) // max(n, 1))
for ri, (letters, color, y, label) in enumerate([
(pl[:n], P['ac'], 18, 'Plano '),
(ky[:n], P['bc'], 52, 'Clave '),
(ci[:n], P['rc'], 86, 'Cifrado'),
]):
cv.create_text(6, y, text=label, anchor=tk.W,
font=('Consolas', 8), fill=P['muted'])
for j, letter in enumerate(letters):
x = 66 + j * cell + cell // 2
if j == 0:
cv.create_rectangle(x-14, y-12, x+14, y+12,
fill=P['panel'], outline=color, width=2)
cv.create_text(x, y, text=letter,
font=('Consolas', 11, 'bold'), fill=color)
# Marcadores de ciclo de clave
if label == 'Clave ':
kl = len(ky)
for j in range(1, n):
if j % kl == 0:
xm = 66 + j * cell
cv.create_line(xm, 34, xm, 70, fill=P['border'], dash=(3, 3))
cv.create_text(xm, 32, text='↺', font=('Consolas', 7),
fill=P['bc'])
# ══════════════════════════════════════════════════════════════════════════
# TAB 3 · SUSTITUCIÓN + FRECUENCIAS
# ══════════════════════════════════════════════════════════════════════════
def _build_substitution(self, parent):
top = tk.Frame(parent, bg=P['bg'], padx=14, pady=10)
top.pack(fill=tk.X)
left = tk.Frame(top, bg=P['bg'])
left.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 18))
# Modo + shuffle
mrow = tk.Frame(left, bg=P['bg'])
mrow.pack(fill=tk.X, pady=(0, 8))
for txt, val, col in [('Cifrar', 'E', P['ac']), ('Descifrar', 'D', P['bc'])]:
tk.Radiobutton(mrow, text=txt, variable=self.sub_mode, value=val,
command=self._update_sub,
bg=P['bg'], fg=col, selectcolor=P['panel2'],
activebackground=P['bg'],
font=('Consolas', 10, 'bold')
).pack(side=tk.LEFT, padx=(0, 18))
mk_btn(left, '🔀 Nueva clave aleatoria', P['btn'],
self._shuffle_sub).pack(fill=tk.X, pady=(0, 10))
for attr, lbl_txt, fg, editable in [
('s_in', 'Texto de entrada', P['ac'], True),
('s_out', 'Resultado', P['rc'], False),
]:
tk.Label(left, text=lbl_txt, font=('Consolas', 9),
bg=P['bg'], fg=P['muted']).pack(anchor=tk.W)
w = tk.Text(left, height=4, width=36,
font=('Consolas', 11), wrap=tk.WORD,
bg=P['panel2'] if editable else P['panel'],
fg=fg, relief=tk.FLAT, insertbackground=fg,
state=tk.NORMAL if editable else tk.DISABLED)
w.pack(pady=(2, 8))
setattr(self, attr, w)
self.s_in.bind('<KeyRelease>', lambda e: self._update_sub())
self.s_in.insert('1.0', 'ESTAMOS APRENDIENDO CRIPTOGRAFIA')
# Tabla de clave
right = tk.Frame(top, bg=P['bg'])
right.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
tk.Label(right, text='Tabla de sustitución (A→? para cada letra)',
font=('Consolas', 9, 'bold'),
bg=P['bg'], fg=P['accent']).pack(anchor=tk.W)
self.s_key_cv = tk.Canvas(right, height=72, bg=P['panel2'],
highlightthickness=0)
self.s_key_cv.pack(fill=tk.X, pady=(4, 0))
self.s_key_cv.bind('<Configure>', lambda e: self._draw_sub_key())
# Histograma de frecuencias
tk.Label(parent,
text='Análisis de Frecuencia (texto cifrado vs. español estándar)',
font=('Consolas', 9, 'bold'), bg=P['bg'], fg=P['muted']
).pack(padx=14, anchor=tk.W, pady=(6, 0))
self.s_freq_cv = tk.Canvas(parent, bg=P['panel2'], highlightthickness=0)
self.s_freq_cv.pack(fill=tk.BOTH, expand=True, padx=14, pady=(4, 10))
self.s_freq_cv.bind('<Configure>', lambda e: self._draw_freq())
self.root.after(120, self._update_sub)
def _shuffle_sub(self):
random.shuffle(self.sub_key)
self._update_sub()
def _update_sub(self, *_):
text = self.s_in.get('1.0', tk.END).strip().upper()
mode = self.sub_mode.get()
fwd = {ALPHA[i]: self.sub_key[i] for i in range(N)}
rev = {v: k for k, v in fwd.items()}
mapping = fwd if mode == 'E' else rev
result = ''.join(mapping.get(ch, ch) if ch in ALPHA else ch for ch in text)
self.s_out.config(state=tk.NORMAL)
self.s_out.delete('1.0', tk.END)
self.s_out.insert('1.0', result)
self.s_out.config(state=tk.DISABLED)
self._sub_result = result
self._sub_mapping = mapping
self._draw_sub_key()
self._draw_freq()
def _draw_sub_key(self):
cv = self.s_key_cv
cv.delete('all')
mapping = self._sub_mapping
if not mapping: return
W = cv.winfo_width() or 700
cw = max(20, W // N)
for i, letter in enumerate(ALPHA):
x = i * cw + cw // 2
mapped = mapping.get(letter, letter)
cv.create_text(x, 14, text=letter, font=('Consolas', 9), fill=P['ac'])
cv.create_text(x, 36, text='↓', font=('Consolas', 8), fill=P['muted'])
cv.create_text(x, 58, text=mapped, font=('Consolas', 9, 'bold'),
fill=P['rc'] if mapped != letter else P['muted'])
def _draw_freq(self):
cv = self.s_freq_cv
cv.delete('all')
W = cv.winfo_width() or 800
H = cv.winfo_height() or 200
if H < 60: return
letters = [c for c in self._sub_result if c in ALPHA]
total = max(len(letters), 1)
ct_freq = {l: letters.count(l) / total * 100 for l in ALPHA}
MAX_F = 16.0
pad_l = 36
pad_b = 28
ch_w = W - pad_l - 10
ch_h = H - pad_b - 20
bot = H - pad_b
top_y = bot - ch_h
gw = ch_w // N
bw = max(3, gw // 2 - 1)
# Eje Y
for pct in [0, 4, 8, 12, 16]:
yp = bot - int(pct / MAX_F * ch_h)
cv.create_line(pad_l - 4, yp, pad_l, yp, fill=P['muted'])
cv.create_text(pad_l - 6, yp, text=f'{pct}%',
anchor=tk.E, font=('Consolas', 7), fill=P['muted'])
# Leyenda
cv.create_rectangle(pad_l, 4, pad_l + 10, 14, fill=P['bc'], outline='')
cv.create_text(pad_l + 14, 9, text='Español estándar',
anchor=tk.W, font=('Consolas', 7), fill=P['muted'])
cv.create_rectangle(pad_l + 130, 4, pad_l + 140, 14, fill=P['rc'], outline='')
cv.create_text(pad_l + 144, 9, text='Texto cifrado',
anchor=tk.W, font=('Consolas', 7), fill=P['muted'])
for i, letter in enumerate(ALPHA):
xb = pad_l + i * gw + 2
# Barra español
es_h = int(min(FREQ_ES[letter], MAX_F) / MAX_F * ch_h)
cv.create_rectangle(xb, bot - es_h, xb + bw, bot,
fill=P['bc'], outline='')
# Barra texto cifrado
ct_h = int(min(ct_freq[letter], MAX_F) / MAX_F * ch_h)
cv.create_rectangle(xb + bw + 1, bot - ct_h,
xb + bw * 2 + 1, bot,
fill=P['rc'], outline='')
cv.create_text(xb + bw, bot + 10, text=letter,
font=('Consolas', 7), fill=P['muted'])
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == '__main__':
root = tk.Tk()
app = CifradosClasicos(root)
root.update_idletasks()
w, h = root.winfo_width(), root.winfo_height()
sw, sh = root.winfo_screenwidth(), root.winfo_screenheight()
root.geometry(f'{w}x{h}+{(sw-w)//2}+{(sh-h)//2}')
root.mainloop()