Módulo 2 · Lección 6 de 28
Tablas y cajas de texto
Calcular anchos de columna, alinear, truncar y dibujar marcos con caracteres Unicode para presentar datos legibles sin instalar nada.
Diez productos impresos con un print por línea son diez líneas que hay que leer de una en una. Los mismos diez en columnas alineadas se leen de un barrido: los nombres a la izquierda, las cantidades en su sitio, los precios en el suyo, y cualquier valor raro salta a la vista porque rompe la vertical.
La alineación ya la sabes hacer: es el {valor:<20} de la lección de f-strings. Lo que falta es el resto del trabajo, que es lo que se hace pesado: decidir cuánto mide cada columna a partir de los datos, recortar lo que no cabe, dibujar los bordes y no descuadrarlo todo con un carácter ancho. Se resuelve una vez, en una función, y ya no se vuelve a pensar en ello.
El problema de los anchos a mano#
El primer intento siempre es este: anchos fijos, escritos a ojo.
filas = [
["Teclado mecánico", 12, 89.9],
["Ratón inalámbrico", 34, 24.5],
["Alfombrilla", 120, 7.95],
]
print(f"{'Producto':<20}{'Unidades':>10}{'Precio':>10}")
print("-" * 40)
for nombre, unidades, precio in filas:
print(f"{nombre:<20}{unidades:>10}{precio:>10.2f}")Producto Unidades Precio
----------------------------------------
Teclado mecánico 12 89.90
Ratón inalámbrico 34 24.50
Alfombrilla 120 7.95Se lee bien, y para una tabla que no va a cambiar nunca es una solución perfectamente válida. Tiene dos problemas: el 20 sobra para estos nombres y se queda corto para el siguiente producto que llegue, y el 40 de la línea de guiones hay que recalcularlo a mano cada vez que se toque una columna.
El ancho sale de los datos#
Lo que mide una columna es lo que mida su valor más largo, cabecera incluida:
cabeceras = ["Producto", "Unidades", "Precio"]
anchos = []
for indice, cabecera in enumerate(cabeceras):
largos = [len(cabecera)] + [len(str(fila[indice])) for fila in filas]
anchos.append(max(largos))
print(anchos)[17, 8, 6]Diecisiete por Ratón inalámbrico, ocho por la cabecera Unidades y seis por Precio, que es más largo que cualquiera de sus valores. Fíjate en el str(): las celdas pueden ser números, y len() de un número no existe.
Con eso, la tabla se dibuja sola. Los anchos entran en las f-strings entre llaves, como viste en la lección de cadenas formateadas:
def tabla_simple(cabeceras: list[str], filas: list[list]) -> str:
"""Devuelve una tabla sin bordes, con cada columna a su ancho."""
anchos = [
max(len(str(cabecera)), *(len(str(fila[indice])) for fila in filas))
for indice, cabecera in enumerate(cabeceras)
]
lineas = [" ".join(f"{c:<{a}}" for c, a in zip(cabeceras, anchos))]
lineas.append(" ".join("-" * a for a in anchos))
for fila in filas:
lineas.append(" ".join(f"{str(v):<{a}}" for v, a in zip(fila, anchos)))
return "\n".join(linea.rstrip() for linea in lineas)
print(tabla_simple(cabeceras, filas))Producto Unidades Precio
----------------- -------- ------
Teclado mecánico 12 89.9
Ratón inalámbrico 34 24.5
Alfombrilla 120 7.95Tres cosas de esa función merecen comentario:
zip(cabeceras, anchos)empareja cada valor con el ancho de su columna. Es el uso de libro dezip, y evita tener que ir arrastrando un índice.- La separación entre columnas está en el
join, no en los anchos. Dos espacios, en un solo sitio; cambiar a tres es cambiar una cadena. - El
rstrip()final quita los espacios de relleno de la última columna, que no aportan nada y ensucian el fichero si alguien redirige la salida.
Queda un detalle chirriando: los números están alineados a la izquierda, y así no se pueden comparar de un vistazo.
Los números, a la derecha#
Con los números pegados a la derecha, las unidades y las decenas se ponen en la misma vertical y la columna se lee como una suma. Con los textos es al revés: alineados a la izquierda se leen; centrados o a la derecha, no.
La regla, entonces, es mirar el tipo del dato:
def es_numero(valor: object) -> bool:
"""True para enteros y decimales, pero no para booleanos."""
return isinstance(valor, (int, float)) and not isinstance(valor, bool)Esa exclusión de los booleanos parece un capricho y no lo es: en Python, bool es una subclase de int, así que isinstance(True, int) vale True. Sin la segunda comprobación, una columna de sí y no se alinearía como si fueran números.
Una columna se alinea a la derecha cuando todos sus valores son números. Si hay uno solo que no lo sea, mejor tratarla como texto:
derechas = [
bool(filas) and all(es_numero(fila[indice]) for fila in filas)
for indice in range(len(cabeceras))
]
print(derechas)[False, True, True]Truncar lo que no cabe#
Un nombre de producto de sesenta caracteres estira su columna y desplaza a las demás fuera de la pantalla. La solución es un ancho máximo y un corte marcado con puntos suspensivos, para que quede claro que hay más texto:
def recortar(texto: str, ancho: int) -> str:
"""Recorta el texto a ese ancho visible y marca el corte con puntos."""
if ancho_visible(texto) <= ancho:
return texto
resultado = ""
for caracter in texto:
if ancho_visible(resultado + caracter) > ancho - 1:
break
resultado += caracter
return resultado + "…"print(recortar("Monitor de 27 pulgadas", 12))Monitor de …El resultado ocupa doce columnas exactas: once de texto y una de los puntos suspensivos. Ese carácter … es uno solo, no tres puntos seguidos, y por eso cuesta una única columna. El ancho - 1 del bucle es lo que le reserva su sitio.
Los caracteres de dibujo#
Unicode tiene un bloque entero de caracteres pensados para dibujar marcos, que en una tipografía de anchura fija encajan unos con otros sin dejar huecos. Estos son los que hacen falta:
| Carácter | Nombre | Para qué |
|---|---|---|
─ | Horizontal | Los tramos de arriba, abajo y el separador |
│ | Vertical | Los lados y las separaciones entre columnas |
┌ ┐ └ ┘ | Esquinas | Las cuatro puntas del marco |
┬ ┴ | Te arriba y abajo | Donde un separador de columna toca el borde |
├ ┤ | Te izquierda y derecha | Donde la línea separadora toca los lados |
┼ | Cruce | El encuentro de la separadora con un separador de columna |
Con once caracteres se dibuja cualquier tabla. Y hay más juegos completos: de línea doble, de esquinas redondeadas y de línea gruesa.
| Juego | Caracteres |
|---|---|
| Simple | ┌┬┐ ├┼┤ └┴┘ ─ │ |
| Doble | ╔╦╗ ╠╬╣ ╚╩╝ ═ ║ |
| Redondo | ╭┬╮ ├┼┤ ╰┴╯ ─ │ |
Agrupados en tuplas de cinco, con las tres filas de uniones, el horizontal y el vertical, se guardan así:
MARCOS = {
"simple": ("┌┬┐", "├┼┤", "└┴┘", "─", "│"),
"doble": ("╔╦╗", "╠╬╣", "╚╩╝", "═", "║"),
"redondo": ("╭┬╮", "├┼┤", "╰┴╯", "─", "│"),
}Y se sacan con un desempaquetado de una línea, que además pone nombre a cada trozo:
superior, intermedia, inferior, horizontal, vertical = MARCOS["simple"]El problema de los caracteres anchos#
Aquí viene la trampa. Una tipografía de anchura fija no es tan fija: hay caracteres que ocupan dos columnas, en particular los ideogramas del chino, el japonés y el coreano, y los emojis. Para Python son un carácter, pero en la pantalla valen dos, así que len() miente:
etiquetas = ["python", "🐍 python", "東京", "café"]
for etiqueta in etiquetas:
print(f"|{etiqueta:<12}| {len(etiqueta)}")|python | 6
|🐍 python | 8
|東京 | 2
|café | 4Mira los cierres de la segunda y la tercera línea: la | no está en la misma columna que en las otras. Python ha rellenado hasta doce caracteres, pero la serpiente se come dos columnas y cada ideograma, otras dos. La cuarta línea, en cambio, sale bien: las letras acentuadas del español son de ancho normal.
La medida de verdad la da unicodedata, que sabe la categoría de ancho de cada carácter:
import unicodedata
def ancho_visible(texto: str) -> int:
"""Columnas que ocupa el texto en pantalla, contando los anchos dobles."""
total = 0
for caracter in texto:
total += 2 if unicodedata.east_asian_width(caracter) in "WF" else 1
return totalLas categorías W (wide) y F (fullwidth) son las que ocupan dos. Con esa función, el relleno se calcula a mano en vez de dejárselo a la f-string:
for etiqueta in etiquetas:
relleno = " " * (12 - ancho_visible(etiqueta))
print(f"|{etiqueta}{relleno}| {ancho_visible(etiqueta)}")|python | 6
|🐍 python | 9
|東京 | 4
|café | 4Ahora las cuatro barras de cierre están en la misma columna.
La función tabla#
Todo junto, en un módulo reutilizable:
"""Tablas de texto para la terminal, solo con la biblioteca estándar."""
import unicodedata
MARCOS = {
"simple": ("┌┬┐", "├┼┤", "└┴┘", "─", "│"),
"doble": ("╔╦╗", "╠╬╣", "╚╩╝", "═", "║"),
"redondo": ("╭┬╮", "├┼┤", "╰┴╯", "─", "│"),
}
def ancho_visible(texto: str) -> int:
"""Columnas que ocupa el texto en pantalla, contando los anchos dobles."""
total = 0
for caracter in texto:
total += 2 if unicodedata.east_asian_width(caracter) in "WF" else 1
return total
def recortar(texto: str, ancho: int) -> str:
"""Recorta el texto a ese ancho visible y marca el corte con puntos."""
if ancho_visible(texto) <= ancho:
return texto
resultado = ""
for caracter in texto:
if ancho_visible(resultado + caracter) > ancho - 1:
break
resultado += caracter
return resultado + "…"
def rellenar(texto: str, ancho: int, derecha: bool = False) -> str:
"""Añade espacios hasta el ancho pedido, contando los anchos dobles."""
huecos = " " * max(0, ancho - ancho_visible(texto))
return huecos + texto if derecha else texto + huecos
def es_numero(valor: object) -> bool:
"""True para enteros y decimales, pero no para booleanos."""
return isinstance(valor, (int, float)) and not isinstance(valor, bool)
def calcular_anchos(cabeceras: list[str], filas: list[list], maximo: int) -> list[int]:
"""El ancho de cada columna: el del dato más largo, con un tope."""
anchos = []
for indice, cabecera in enumerate(cabeceras):
largos = [ancho_visible(str(cabecera))]
largos += [ancho_visible(str(fila[indice])) for fila in filas]
anchos.append(min(max(largos), maximo))
return anchos
def tabla(
cabeceras: list[str],
filas: list[list],
marco: str = "simple",
maximo: int = 30,
derechas: list[bool] | None = None,
) -> str:
"""Devuelve las filas dibujadas como una tabla con bordes."""
superior, intermedia, inferior, horizontal, vertical = MARCOS[marco]
anchos = calcular_anchos(cabeceras, filas, maximo)
if derechas is None:
derechas = [
bool(filas) and all(es_numero(fila[indice]) for fila in filas)
for indice in range(len(cabeceras))
]
def borde(esquinas: str) -> str:
izquierda, centro, derecha = esquinas
tramos = (horizontal * (ancho + 2) for ancho in anchos)
return izquierda + centro.join(tramos) + derecha
def fila_de_texto(celdas: list, alineaciones: list[bool]) -> str:
casillas = []
for celda, ancho, derecha in zip(celdas, anchos, alineaciones):
texto = recortar(str(celda), ancho)
casillas.append(" " + rellenar(texto, ancho, derecha) + " ")
return vertical + vertical.join(casillas) + vertical
lineas = [
borde(superior),
fila_de_texto(cabeceras, [False] * len(cabeceras)),
borde(intermedia),
]
lineas += [fila_de_texto(fila, derechas) for fila in filas]
lineas.append(borde(inferior))
return "\n".join(lineas)Las dos funciones de dentro, borde y fila_de_texto, están definidas ahí porque solo tienen sentido ahí: usan anchos, horizontal y vertical sin recibirlos por parámetro, gracias a lo que viste en la lección de ámbito de las variables. El + 2 de borde es el hueco de un espacio a cada lado de cada celda.
Funcionando:
from tabla import tabla
cabeceras = ["Producto", "Unidades", "Precio"]
filas = [
["Teclado mecánico", 12, 89.9],
["Ratón inalámbrico", 34, 24.5],
["Monitor de 27 pulgadas y panel IPS", 3, 249.0],
]
print(tabla(cabeceras, filas))┌────────────────────────────────┬──────────┬────────┐
│ Producto │ Unidades │ Precio │
├────────────────────────────────┼──────────┼────────┤
│ Teclado mecánico │ 12 │ 89.9 │
│ Ratón inalámbrico │ 34 │ 24.5 │
│ Monitor de 27 pulgadas y pane… │ 3 │ 249.0 │
└────────────────────────────────┴──────────┴────────┘El mismo dato, más estrecho y con línea doble:
print(tabla(cabeceras, filas, marco="doble", maximo=20))╔══════════════════════╦══════════╦════════╗
║ Producto ║ Unidades ║ Precio ║
╠══════════════════════╬══════════╬════════╣
║ Teclado mecánico ║ 12 ║ 89.9 ║
║ Ratón inalámbrico ║ 34 ║ 24.5 ║
║ Monitor de 27 pulga… ║ 3 ║ 249.0 ║
╚══════════════════════╩══════════╩════════╝Los precios salen como 89.9 y 249.0, que para una factura no vale. La solución no es complicar la función, sino darle los datos ya formateados y decirle cómo alinear:
formateadas = [
["Teclado mecánico", "12", "89,90 €"],
["Ratón inalámbrico", "34", "24,50 €"],
["Alfombrilla", "120", "7,95 €"],
]
print(tabla(cabeceras, formateadas, derechas=[False, True, True]))┌───────────────────┬──────────┬─────────┐
│ Producto │ Unidades │ Precio │
├───────────────────┼──────────┼─────────┤
│ Teclado mecánico │ 12 │ 89,90 € │
│ Ratón inalámbrico │ 34 │ 24,50 € │
│ Alfombrilla │ 120 │ 7,95 € │
└───────────────────┴──────────┴─────────┘Esa es la división de responsabilidades que interesa: la tabla dibuja, no decide el formato de los números. Cuántos decimales lleva un precio o cómo se escribe una fecha es cosa de quien conoce los datos.
Y con caracteres de ancho doble sigue cuadrando, que era lo que se buscaba con ancho_visible:
print(tabla(["Ciudad", "Habitantes"], [["東京", 13960000], ["Madrid", 3223000]]))┌────────┬────────────┐
│ Ciudad │ Habitantes │
├────────┼────────────┤
│ 東京 │ 13960000 │
│ Madrid │ 3223000 │
└────────┴────────────┘La función caja#
La otra necesidad habitual es enmarcar un mensaje, sin columnas. Se resuelve con las mismas piezas y sirve para destacar avisos y para dibujar el encabezado de un menú:
def caja(lineas: list[str], marco: str = "simple", titulo: str = "") -> str:
"""Rodea las líneas con un marco del ancho de la más larga."""
superior, _, inferior, horizontal, vertical = MARCOS[marco]
ancho = max(ancho_visible(linea) for linea in lineas)
ancho = max(ancho, ancho_visible(titulo) + 2)
izquierda, _, derecha = superior
if titulo:
relleno = horizontal * (ancho - ancho_visible(titulo) - 1)
arriba = f"{izquierda}{horizontal} {titulo} {relleno}{derecha}"
else:
arriba = izquierda + horizontal * (ancho + 2) + derecha
izquierda, _, derecha = inferior
abajo = izquierda + horizontal * (ancho + 2) + derecha
medio = [f"{vertical} {rellenar(linea, ancho)} {vertical}" for linea in lineas]
return "\n".join([arriba, *medio, abajo])print(caja(["Copia terminada.", "24 ficheros, 3,4 MB."]))
print(caja(["No se ha encontrado config.toml.", "Se usarán los valores por defecto."], marco="doble"))
print(caja(["Pulsa [1] para continuar", "Pulsa [0] para salir"], marco="redondo", titulo="AYUDA"))┌──────────────────────┐
│ Copia terminada. │
│ 24 ficheros, 3,4 MB. │
└──────────────────────┘
╔════════════════════════════════════╗
║ No se ha encontrado config.toml. ║
║ Se usarán los valores por defecto. ║
╚════════════════════════════════════╝
╭─ AYUDA ──────────────────╮
│ Pulsa [1] para continuar │
│ Pulsa [0] para salir │
╰──────────────────────────╯Ese guion bajo de superior, _, inferior, ... es la costumbre de Python para decir "este valor no me interesa": la fila de uniones intermedias no hace falta en una caja sin columnas.