← Automatización con Scripts en Bash Bloque 7 · Texto y expresiones regulares · Tema 31 de 42

Tema 31

Generar informes y plantillas

Muchos scripts terminan produciendo algo para que lo lea una persona: un resumen en la terminal, un informe que se envía por correo, una página HTML con el estado de los servidores, un archivo de configuración generado a partir de una plantilla. Este tema reúne las herramientas para hacerlo bien: printf para dar formato, column para alinear tablas, los documentos en línea para escribir bloques de texto, y las precauciones necesarias cuando el resultado es HTML.

1. printf a fondo

Desde el tema 4, el curso usa printf para mostrar datos. Su primer argumento es un formato, con marcas que indican dónde y cómo va cada dato; los demás argumentos son los datos. Estas son las marcas que más se usan en informes:

MarcaMuestraEjemploResultado
%sUn texto.printf '%s' holahola
%-10sUn texto alineado a la izquierda, en 10 posiciones.printf '[%-10s]' hola[hola ]
%10sAlineado a la derecha.printf '[%10s]' hola[ hola]
%d, %5dUn entero, con ancho opcional.printf '%5d' 42 42
%05dUn entero completado con ceros.printf '%05d' 4200042
%.2f, %8.2fUn decimal con dos cifras, con ancho opcional.printf '%8.2f' 3.14159 3.14
%x, %oUn entero en hexadecimal o en octal.printf '%x' 255ff
%%Un signo de porcentaje.printf '%d %%' 8484 %
%*sEl ancho viene como argumento.printf '[%*s]' 6 x[ x]

Tres comportamientos de printf resultan muy útiles en informes:

Y recuerda la advertencia del tema 7: el printf de Bash respeta la configuración regional para los decimales. En un sistema en español, %.2f espera una coma decimal en el dato; si el número viene con punto, conviene ejecutarlo con LC_NUMERIC=C o dar formato en awk.

2. Anchos de columna y letras acentuadas

Los anchos de printf tienen un límite que se nota enseguida en español: el printf de Bash cuenta bytes, no caracteres. En UTF-8, una letra acentuada o una eñe ocupan dos bytes, de modo que una palabra con tilde recibe un espacio menos de relleno y la columna se desalinea:

terminal
$ printf '[%-8s]\n' anio año
[anio    ]
[año    ]

Para textos que pueden tener acentos, como nombres, descripciones o los propios encabezados de las columnas, hay dos soluciones: dejar la columna con texto libre al final de la línea, donde no importa su ancho, como hicieron los scripts de los temas anteriores con los nombres de archivo; o dejar que alinee column, que en Linux sí cuenta caracteres.

3. Tablas con column

column -t recibe líneas con campos separados y las alinea en columnas, calculando el ancho de cada una a partir de su contenido. Con -s se indica el separador; un tabulador es la elección más segura, porque no aparece en los datos:

terminal
$ printf 'Archivo\tTamaño\tDías\n' > tabla.tsv
$ printf 'web-2026-10-14.tar.gz\t45.7 MiB\t0\nnotas.zip\t50.7 KiB\t20\n' >> tabla.tsv
$ column -t -s $'\t' tabla.tsv
Archivo                Tamaño    Días
web-2026-10-14.tar.gz  45.7 MiB  0
notas.zip              50.7 KiB  20

La ventaja sobre printf es que no hay que conocer los anchos de antemano: column lee todo y ajusta cada columna al valor más largo. La contracara es que necesita tener toda la tabla antes de mostrar la primera línea, y que descarta las líneas vacías. Por eso se usa solo para la tabla, no para el informe entero, como hace el script de la sección 12. El separador por tabulador permite además que los campos tengan espacios, como 45.7 MiB.

4. Documentos en línea

Para escribir un bloque de varias líneas, como el encabezado de un informe, un mensaje de ayuda o una página HTML, una sucesión de echo es incómoda. Un documento en línea (here document) pasa un bloque de texto, escrito directamente en el script, como entrada estándar de un comando:

un documento en línea
cat <<FIN
Informe de respaldos — $(hostname)
Generado el $(date '+%Y-%m-%d %H:%M') por $USER.
FIN

<<FIN indica que lo que sigue, hasta una línea que contenga solo FIN, es la entrada del comando. La palabra de cierre puede ser cualquiera; FIN y EOF son las habituales. Ya usamos esta forma para los mensajes de uso en los temas 13, 20 y 21.

La línea de cierre debe contener la palabra sola, sin espacios antes ni después. Un espacio de más, o una sangría, hace que Bash no la reconozca y siga leyendo el documento hasta el final del archivo, con un aviso del tipo here-document delimited by end-of-file.

5. Expandir o no expandir

Dentro de un documento en línea, Bash expande las variables, las sustituciones de comandos y la aritmética, como dentro de comillas dobles. Si se entrecomilla la palabra de apertura, el texto se toma literalmente:

FormaEfectoUso típico
<<FINExpande $var, $( ) y $(( )).Informes, mensajes y plantillas con datos.
<<'FIN'Texto literal: nada se expande.Escribir un script, código o texto con muchos $.
<<-FINQuita los tabuladores al comienzo de cada línea.Sangrar el documento dentro de una función o un if.
terminal
$ nombre=Ana
$ cat <<FIN
> Hola, $nombre. Es $(date +%Y).
> FIN
Hola, Ana. Es 2026.
$ cat <<'FIN'
> Hola, $nombre. Es $(date +%Y).
> FIN
Hola, $nombre. Es $(date +%Y).

La forma literal es la adecuada cuando un script genera otro script, como hizo nuevo-script en el tema 2, o un archivo de configuración con variables que deben quedar escritas tal cual. Dentro de un documento que sí expande, un $ literal se escribe \$.

La forma <<- solo quita tabuladores, no espacios. Como la guía de estilo del curso usa espacios para la sangría, en la práctica es más simple escribir el documento sin sangría, aunque esté dentro de una función.

6. A dónde enviar un documento en línea

Un documento en línea es la entrada estándar de un comando, y puede ser la de cualquiera. Algunos usos habituales:

documentos en línea con distintos destinos
cat > /etc/app/config.ini <<FIN              # escribir un archivo
[servidor]
puerto = $PUERTO
FIN

ssh servidor 'bash -s' <<'FIN'                # ejecutar un bloque en otra máquina
df -h /
systemctl is-active nginx
FIN

psql -d ventas <<FIN                          # consultas a una base de datos
SELECT count(*) FROM pedidos WHERE fecha = '$hoy';
FIN

mail -s "Informe del $(date +%F)" admin@example.com < informe.txt   # o con un archivo

El ejemplo de psql tiene la misma trampa que vimos con sed en el tema 29: insertar una variable en el texto de una consulta es seguro solo si su valor está controlado. Si viene del usuario, puede alterar la consulta; las bases de datos tienen mecanismos propios, como las variables de psql, para pasar valores aparte.

7. Plantillas

Una plantilla es un texto con huecos que se completan con datos: la configuración de un servidor web para cada sitio, un mensaje de bienvenida para cada usuario, un archivo de servicio para cada aplicación. Hay tres formas de manejarlas en Bash, según dónde esté la plantilla:

FormaLa plantilla estáCuándo
Un documento en línea con variablesDentro del script.Plantillas breves, que cambian junto con el script.
envsubstEn un archivo aparte, con ${VARIABLE}.Plantillas que mantiene otra persona, o que son largas.
Reemplazos con ${texto//marca/valor}En un archivo, con marcas propias como {{NOMBRE}}.Cuando la plantilla tiene $ que no deben tocarse.
una plantilla en un documento en línea
generar_sitio() {
  local dominio="$1" raiz="$2"
  cat <<FIN
server {
    listen 80;
    server_name $dominio;
    root $raiz;
    access_log /var/log/nginx/$dominio.log;
}
FIN
}

generar_sitio ejemplo.com /srv/ejemplo > /etc/nginx/sites-available/ejemplo.com

Lo que no debe hacerse es leer una plantilla de un archivo y evaluarla con eval para que se expandan sus variables: eval ejecutaría también cualquier $(…) que contenga, con el mismo riesgo que source en el tema 30.

8. envsubst

envsubst, del paquete gettext, lee un texto y reemplaza las referencias $VARIABLE o ${VARIABLE} por los valores de las variables de entorno. No ejecuta nada: solo reemplaza, de modo que es la forma segura de completar una plantilla guardada en un archivo.

bienvenida.tmpl
Hola, ${NOMBRE}:

Tu cuenta en ${SERVIDOR} ya está lista. El costo mensual es de $5.
terminal
$ NOMBRE=Ana SERVIDOR=web01 envsubst '$NOMBRE $SERVIDOR' < bienvenida.tmpl
Hola, Ana:

Tu cuenta en web01 ya está lista. El costo mensual es de $5.

Dos detalles: las variables deben estar en el entorno, exportadas o definidas para ese comando, como en el ejemplo; y conviene indicar, como primer argumento, la lista de variables a reemplazar. Sin esa lista, envsubst reemplaza cualquier $algo, y una variable que no existe se convierte en vacío: el $5 del ejemplo desaparecería. Con la lista, el resto de los $ quedan intactos.

9. Generar HTML sin romperlo

Un informe en HTML se ve bien en un navegador, se puede enviar por correo y se puede publicar en un servidor web. Pero tiene una exigencia: los datos que se insertan en él deben escaparse. Un nombre de archivo como notas <viejas> & más.zip, insertado tal cual, rompe la página, porque el navegador interpreta <viejas> como una etiqueta. Y si los datos vienen de otras personas, insertarlos sin escapar permite inyectar código en la página.

Escapar consiste en reemplazar los caracteres especiales de HTML por sus entidades:

escapar texto para HTML
# escapar_html TEXTO — reemplaza los caracteres especiales de HTML.
escapar_html() {
  local t="$1"
  t="${t//&/\&amp;}"                # primero el &, para no escapar los que agregamos
  t="${t//</\&lt;}"
  t="${t//>/\&gt;}"
  t="${t//\"/\&quot;}"
  printf '%s' "$t"
}
terminal
$ escapar_html 'a < b & "c" > d'; echo
a &lt; b &amp; &quot;c&quot; &gt; d
La barra antes de cada &

Desde Bash 5.2, en el reemplazo de ${var//patrón/reemplazo} un & significa «lo que coincidió», como en sed. Sin la barra, ${t//</&lt;} produciría <lt; en lugar de &lt;. La barra lo vuelve literal, y funciona igual en las versiones anteriores, que no tenían este comportamiento. Es el detalle que señalamos en el tema 6.

El orden importa: el & se escapa primero, porque si no, los & de las entidades que se agregan después se escaparían otra vez. Para Markdown la situación es más benigna: el texto se ve tal cual, aunque caracteres como | pueden romper una tabla, y * o _ pueden activar formatos.

10. Separar los datos de la presentación

Un informe tiene dos partes que conviene no mezclar: obtener y calcular los datos, y presentarlos en un formato. Mezclarlas lleva a scripts donde cada línea de cálculo está pegada a un printf, y agregar un segundo formato obliga a duplicar todo.

  1. Reunir los datos en variables y arrays: las filas de la tabla, los totales, las fechas. Sin mostrar nada. calcular
  2. Presentarlos, con una función por formato: informe_texto, informe_md, informe_html. Cada una solo lee los datos ya reunidos y les da forma. presentar
  3. Entregar el resultado: a la salida, a un archivo o por correo. entregar

Con esa estructura, agregar un formato es agregar una función, y la elección se resuelve en una línea: si las funciones se llaman informe_ seguido del nombre del formato, "informe_$formato" ejecuta la que corresponde. Como el formato se validó antes contra una lista cerrada, no hay riesgo de ejecutar otra cosa.

11. Entregar el informe

Un informe que va a un archivo, especialmente uno que publica un servidor web o que lee otro programa, debe escribirse de forma atómica, como en el tema 24: en un temporal del mismo directorio, que se renombra al terminar. Así, nadie lee nunca un informe a medio escribir, y si el script falla, queda el informe anterior.

Para enviarlo por correo, el comando mail (del paquete mailutils o bsd-mailx), si el equipo tiene configurado el envío, recibe el cuerpo por la entrada estándar:

enviar un informe
informe-respaldos /respaldos | mail -s "Respaldos de $(hostname) — $(date +%F)" admin@example.com

En muchos casos ni siquiera hace falta: como veremos en el tema 34, cron envía por correo todo lo que una tarea escribe en su salida. Un informe programado en cron llega solo.

12. Un script completo

Este script genera un informe de los respaldos de un directorio: nombre, tamaño, antigüedad y estado de cada uno, marcando los que superan una antigüedad, y un resumen. Puede producirlo en texto para la terminal, en Markdown o en HTML, y escribirlo en un archivo de forma atómica.

informe-respaldos
#!/usr/bin/env bash
#
# informe-respaldos — informe de los respaldos de un directorio, en texto, Markdown o HTML.
#
# Uso: informe-respaldos [-f texto|md|html] [-d DÍAS] [-o ARCHIVO] DIRECTORIO
#   -f FORMATO  formato del informe (texto)
#   -d DÍAS     antigüedad a partir de la cual un respaldo se marca como viejo (7)
#   -o ARCHIVO  escribir el informe en ARCHIVO en lugar de la salida estándar
#
set -euo pipefail

uso() {
  echo "Uso: ${0##*/} [-f texto|md|html] [-d DÍAS] [-o ARCHIVO] DIRECTORIO" >&2
  exit 2
}

# a_legible BYTES — «1.5 KiB», «2.7 MiB»… (tema 19)
a_legible() {
  local bytes="$1"
  local -a unidades=(B KiB MiB GiB TiB)
  local i=0 decimas=$(( bytes * 10 ))
  while (( decimas >= 10240 && i < ${#unidades[@]} - 1 )); do
    decimas=$(( decimas / 1024 ))
    i=$(( i + 1 ))
  done
  if (( i == 0 )); then
    printf '%d B\n' "$bytes"
  else
    printf '%d.%d %s\n' $(( decimas / 10 )) $(( decimas % 10 )) "${unidades[i]}"
  fi
}

# escapar_html TEXTO — reemplaza los caracteres especiales de HTML.
escapar_html() {
  local t="$1"
  t="${t//&/\&amp;}"
  t="${t//</\&lt;}"
  t="${t//>/\&gt;}"
  t="${t//\"/\&quot;}"
  printf '%s' "$t"
}

formato=texto
dias=7
salida=""
while getopts ":f:d:o:" opcion; do
  case "$opcion" in
    f) formato="$OPTARG" ;;
    d) dias="$OPTARG" ;;
    o) salida="$OPTARG" ;;
    *) uso ;;
  esac
done
shift $(( OPTIND - 1 ))
(( $# == 1 )) || uso
dir="$1"
[[ "$formato" =~ ^(texto|md|html)$ ]] || uso
[[ "$dias" =~ ^[0-9]+$ ]] || uso
[[ -d "$dir" ]] || { echo "${0##*/}: no existe el directorio $dir" >&2; exit 1; }

# 1. Reunir los datos: una fila por respaldo, del más nuevo al más viejo.
ahora=$(date +%s)
total=0
viejos=0
tabla=()                                  # nombre, tamaño, días y estado, separados por tabuladores
while IFS=$'\t' read -r epoca bytes nombre; do
  edad=$(( (ahora - ${epoca%.*}) / 86400 ))
  estado=ok
  if (( edad > dias )); then
    estado=viejo
    viejos=$(( viejos + 1 ))
  fi
  total=$(( total + bytes ))
  tabla+=("$nombre"$'\t'"$(a_legible "$bytes")"$'\t'"$edad"$'\t'"$estado")
done < <(find "$dir" -maxdepth 1 -type f \( -name '*.tar.gz' -o -name '*.tgz' -o -name '*.zip' \) \
              -printf '%T@\t%s\t%f\n' | sort -rn)

titulo="Respaldos en $dir"
resumen="${#tabla[@]} respaldos, $(a_legible "$total") en total, $viejos con más de $dias días."
generado=$(date '+%Y-%m-%d %H:%M')

# 2. Presentarlos: una función por formato.
informe_texto() {
  printf '%s\n\n' "$titulo"
  {
    printf 'Archivo\tTamaño\tDías\tEstado\n'
    (( ${#tabla[@]} == 0 )) || printf '%s\n' "${tabla[@]}"
  } | column -t -s $'\t'
  printf '\n%s\nGenerado el %s.\n' "$resumen" "$generado"
}

informe_md() {
  local fila nombre tam edad estado
  printf '# %s\n\n| Archivo | Tamaño | Días | Estado |\n|---|---:|---:|---|\n' "$titulo"
  for fila in "${tabla[@]}"; do
    IFS=$'\t' read -r nombre tam edad estado <<< "$fila"
    printf '| %s | %s | %s | %s |\n' "$nombre" "$tam" "$edad" "$estado"
  done
  printf '\n%s  \nGenerado el %s.\n' "$resumen" "$generado"
}

informe_html() {
  local fila nombre tam edad estado
  cat <<FIN
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <title>$(escapar_html "$titulo")</title>
  <style>
    body { font-family: sans-serif; margin: 2rem; }
    th, td { padding: .3rem .8rem; text-align: left; }
    .viejo { color: #b00; }
  </style>
</head>
<body>
<h1>$(escapar_html "$titulo")</h1>
<table>
<tr><th>Archivo</th><th>Tamaño</th><th>Días</th><th>Estado</th></tr>
FIN
  for fila in "${tabla[@]}"; do
    IFS=$'\t' read -r nombre tam edad estado <<< "$fila"
    printf '<tr class="%s"><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>\n' \
      "$estado" "$(escapar_html "$nombre")" "$tam" "$edad" "$estado"
  done
  cat <<FIN
</table>
<p>$(escapar_html "$resumen")<br>Generado el $generado.</p>
</body>
</html>
FIN
}

# 3. Entregarlos: en la salida, o en un archivo de forma atómica.
if [[ -n "$salida" ]]; then
  tmp=$(mktemp "$salida.XXXXXX")
  trap 'rm -f "$tmp"' EXIT
  "informe_$formato" > "$tmp"
  chmod 644 "$tmp"
  mv -f -- "$tmp" "$salida"
  echo "Informe escrito en $salida" >&2
else
  "informe_$formato"
fi
terminal
$ informe-respaldos /respaldos
Respaldos en /respaldos

Archivo                   Tamaño    Días  Estado
web-2026-10-14.tar.gz     45.7 MiB  0     ok
web-2026-10-13.tar.gz     44.8 MiB  1     ok
notas <viejas> & más.zip  50.7 KiB  20    viejo

3 respaldos, 90.6 MiB en total, 1 con más de 7 días.
Generado el 2026-10-14 13:14.
$ informe-respaldos -f html -o /srv/web/respaldos.html /respaldos
Informe escrito en /srv/web/respaldos.html
$ grep 'class="viejo"' /srv/web/respaldos.html
<tr class="viejo"><td>notas &lt;viejas&gt; &amp; más.zip</td><td>50.7 KiB</td><td>20</td><td>viejo</td></tr>

13. Errores frecuentes

EscribisteQué pasaCorrección
printf "$texto"Un % o una barra en el texto se interpretan como formato.printf '%s' "$texto"
printf '%-10s' con palabras acentuadasLa columna se desalinea.Texto libre al final, o column -t.
La palabra de cierre con sangría o espaciosEl documento no termina hasta el final del archivo.Sola, al comienzo de la línea.
<<FIN para generar un scriptSus variables se expanden al generarlo.<<'FIN'.
<<-FIN con sangría de espaciosSolo quita tabuladores.Escribir el documento sin sangría.
eval para completar una plantillaEjecuta cualquier $(…) que contenga.envsubst con una lista de variables.
Datos sin escapar en HTMLLa página se rompe, o se inyecta código.escapar_html.
${t//</&lt;} en Bash 5.2Produce <lt;.${t//</\&lt;}
Escribir el informe directamente en su destinoOtros pueden leerlo a medio escribir.Temporal y mv.

14. Resumen

15. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué muestra printf '%-6s|%4d|\n' ab 7 cd 42?

Dos líneas, porque el formato se repite: ab | 7| y cd | 42|.

2. ¿Por qué printf "$mensaje" es peligroso?

Porque el contenido de la variable se usa como formato: un % o una barra invertida en el mensaje se interpretan. Se escribe printf '%s\n' "$mensaje".

3. ¿Por qué se desalinea una columna con printf '%-10s' cuando los datos tienen tildes?

Porque printf cuenta bytes, y en UTF-8 una letra acentuada ocupa dos. La palabra recibe un espacio menos de relleno por cada letra acentuada.

4. ¿Qué ventaja tiene column -t sobre printf para una tabla?

Calcula el ancho de cada columna a partir de los datos, sin conocerlo de antemano, y en Linux cuenta caracteres, de modo que las letras acentuadas no desalinean.

5. ¿Qué diferencia hay entre <<FIN y <<'FIN'?

Con <<FIN se expanden las variables, las sustituciones de comandos y la aritmética. Con <<'FIN', el texto se toma literalmente.

6. ¿Qué pasa si la línea de cierre de un documento en línea tiene un espacio al final?

Bash no la reconoce como cierre, y sigue leyendo el documento hasta el final del archivo, con un aviso.

7. ¿Por qué conviene pasarle a envsubst la lista de variables a reemplazar?

Porque sin ella reemplaza cualquier $algo del texto, y las variables que no existen se convierten en vacío. Con la lista, solo se reemplazan esas, y los demás $ quedan intactos.

8. ¿Por qué el & se escapa primero al escapar HTML?

Porque las entidades que se agregan después, como &lt;, contienen un &. Si se escapara al final, esos & se convertirían otra vez en &amp;.

9. ¿Por qué en Bash 5.2 hay que escribir \& en el reemplazo de ${var//…/…}?

Porque desde esa versión un & en el reemplazo representa el texto que coincidió con el patrón, como en sed. La barra lo vuelve literal.

10. ¿Qué ventaja tiene separar el cálculo de los datos de su presentación?

Que se puede presentar los mismos datos en varios formatos sin repetir el cálculo, y agregar un formato es agregar una función que solo da forma a datos ya reunidos.

Para practicar

  1. Escribe con printf una tabla de tres columnas con datos sin acentos, y otra con nombres acentuados. Compárala con la misma tabla pasada por column -t.
  2. Escribe un documento en línea que genere un pequeño script, con <<'FIN', y comprueba que sus variables quedaron sin expandir.
  3. Crea una plantilla de mensaje con ${NOMBRE} y un precio con $, y complétala con envsubst con y sin la lista de variables.
  4. Prueba la función escapar_html con y sin las barras antes de &, y comprueba tu versión de Bash con echo $BASH_VERSION.
  5. Agrega a informe-respaldos un formato csv con su función.
  6. Genera el informe en HTML, ábrelo en un navegador, y prográmalo para que se regenere cada hora en un directorio que sirva un servidor web, como el de servidor-web del tema 13.