Generando un listado de fotos a partir de una hoja de cálculo

En los centros educativos de tamaño mediano o grande, resulta de gran ayuda disponer de una orla de fotos del personal y el alumnado para poder poner cara a tantos nombres. Las herramientas educativas de gestión suelen proporcionar de forma automatizada la generación del listado de fotos del alumno, una herramienta muy útil para los equipos educativos. No obstante, no suele ser tan habitual proporcionar el listado con las fotos de los compañeros y compañeras. Así, puede ser habitual pasar meses sin ponerle cara a una persona con la que estás intercambiando correos, o que imparte clase en tu tutoría, o que lleva determinada coordinación. Esto se solucionaría con algo tan sencillo como un libro de fotos de profesorado (y PAS) disponible en la sala de profesorado y en la conserjería del centro.

Hace años, durante la pandemia tuve que elaborar un libro de estas características por motivos obvios: ese curso, directamente no podíamos siquiera vernos la cara entera. Aquello fue un trabajo bastante tedioso: pedir a todo el personal que enviase la foto, redimensionar y convertir a un formato uniforme, elaborar una tabla con el listado de personal por departamentos, añadir las fotos y finalmente, imprimir a color. El problema es que cuando terminé, el libro ya estaba obsoleto por las bajas y altas de personal que se habían producido en el impás. Así que, el libro, cada vez que se imprimió ya estuvo desactualizado.

Así que me anoté el estudiar como podría hacer esto mismo de una forma más automatizada. Y hasta hoy.

En este post, voy a compartir una solución para generar un libro de fotos en cuestión de segundos únicamente usando LibreOffice Calc.

La fuente de datos

Para empezar, partimos de una hoja de cálculo que contenga las siguientes columnas:

  • ID: Un identificador secuencial
  • MAIL: El correo del docente o PAS
  • NOM: Nombre completo. En mi caso, el nombre y los dos apellidos. Por lo que no puedo usar este campo para ordenar.
  • FOTO: Nombre del archivo de la foto.
  • DEPT: Nombre del departamento al que pertenece el docente o PAS.
  • ORDEN: Campo para ordenar. Básicamente son los dos apellidos.
  • TIPUS: Para indicar si es docente o PAS

La generación de este fichero es el paso previo y necesario para llevar a cabo la generación del libro de fotos. El fichero se ordena en varios niveles: TIPUS, DEPT, ORDEN. De esa manera, tenemos el listado ordenado tal y como queremos que salga en el libro de fotos.

Ejemplo de listado con datos fake

Vale, ya tenemos la hoja de cálculo. ¿Y ahora cómo lo convertimos en un PDF con el libro de fotos?

XML y XSLT

Un XML (eXtensible Markup Language) es un lenguaje de marcado que se usa para guardar y organizar datos de forma estructurada.

<persona>
  <nombre>Ana</nombre>
  <edad>20</edad>
</persona>

Donde <persona> representa un elemento principal y <nombre> y <edad> elementos estructurados. En XML es muy importante la estructura y todo elemento, tiene que tener apertura y cierre (</persona>) de forma estructurada.

XML se puede combinar con XSLT, que significa Extensible Stylesheet Language Transformations, para transformar un XML en otro formato más amigable.

<xsl:template match="/persona">
  <html>
    <body>
      <h1><xsl:value-of select="nombre"/></h1>
      <p>Edad: <xsl:value-of select="edad"/></p>
    </body>
  </html>
</xsl:template>

Esta transformación, aplicada al XML anterior devolvería un HTML con los datos mostrados en un encabezado y un párrafo.

La idea es utilizar las transformaciones XSLT que se definen para los documentos XML para formatear la visualización de la hoja de cálculo como un libro de fotos en HTML.

Seguramente os estaréis preguntando que una hoja de cálculo de LibreOffice no es un documento XML, sino un ODS, O quizá ya sepáis que, en realidad, el formato Open Document Spreadheet almacena los datos en un XML.

Para comprobarlo, os sugiero que hagáis el siguiente experimento:

  1. Renombrad vuestro archivo ODS a ZIP. Por ejemplo lista_docentes_pas.ods lo renombráis a lista_docentes_pas.zip
  2. Abrís el ZIP resultantes
  3. Extraeis el archivo content.xml que hay en su interior
  4. Lo abrís con un editor de texto
Los datos de la hoja de cálculo por dentro

Como podéis ver, en el fondo, tenemos un XML organizando los datos de las celdas. Pues es con eso con lo que vamos a trabajar.

La magia del XSLT

El lenguaje de etiquetado para las transformaciones XSLT nos permite dotar de bastante expresividad a la hora de definir las transformaciones. Así, podemos crear zonas que se repitan para todos los elementos que cumplan determinado criterio (por ejemplo, iterar entre todos las personas de un determinado departamento).

En este caso, la página HTML que vamos a generar tendrá dos secciones: un índice con todos los departamentos, con un enlace que nos llevará a la página correspondiente y luego, para cada departamento, las fotos de las personas integrantes.

Primera página
Páginas sucesivas (con la foto mockup)

A continuación comparto el archivo con la transformación XSLT que, a partir de la hoja de cálculo genera el HTML.

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" exclude-result-prefixes="office table text">
<!-- XSL para obtener la orla de profesores a partir de hoja de cálculo -->
<!-- Los datos han de estar previamente ordenados por tipo personal, departamento y nombre -->
 <xsl:template match="/"> 
 <html lang="es">
 <head>
        <meta charset="UTF-8"/>
        <title>Fotos profes i PAS</title>
        <style>

body {
    font-family: Arial, Helvetica, sans-serif;
    counter-reset: page;
}
 a, a:visited {
       color: cadetblue;
 }

 a:hover{
        color:darkgoldenrod;

 }


.seccion {
    padding-top: 35px;
}


.menu-item{
    margin-left: 20px;
}

#cabecera {
    color:darkgoldenrod;
    text-transform: uppercase;
    text-align: center;
    font-size: 0.9em;
    font-weight: bold;
    border-bottom: 3px darkgoldenrod solid;
    position: fixed;
    top: 0;
    width: 100%;
    height: 20px;
    background-color: white;
    z-index: 99;
  
}

.pie{
    text-align: center;
    width: 100%;
    height: 20px;
    border-top: 3px darkgoldenrod solid;
    background-color: white;
    z-index: 99;
    
}

.nom-seccion{
    font-weight: bold;
    font-size: 0.9em;
}

.nom-seccion span{
   color: cadetblue;
}

.wrapper {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-start;
    border: 1px solid black;
    padding: 10px 40px;    
}

.personal {
    width: 120px;
    font-size: 0.7em;
    padding-top: 10px;
}

.personal img{
    width: 100px;
    height: 100px;
    object-fit: contain;
}

.nom {
    width: 100px;
    font-weight: bold;
    margin-top: 5px;
}



@media print {
  .seccion {
    min-height: 25.3cm;
    margin-top: 0px;
  }

   .pie{
    break-after: always;
  }
 
  .personal {
    page-break-inside: avoid;
  }

  
  .page-number:before  {
        
        counter-increment: page;
        content: counter(page);

  }


}
        </style>
</head>
<body>
    <div id="cabecera">Llistat del personal per departament, amb foto</div>
    <xsl:apply-templates select="/*/office:body" />
</body>
</html>
</xsl:template> 
  
 <xsl:template match="office:spreadsheet/table:table">
    <!-- PRIMERA PÁGINA CON ÍNDICE -->
    <div class="seccion">
        <div class="nom-seccion"><span>ÍNDEX</span></div>
        <div class="wrapper">
            <ol>
            <!-- Recorremos todas las entradas -->
            <xsl:for-each select="table:table-row[position() &gt; 1]">
                <xsl:variable name="key" select="./table:table-cell[5]/text:p" />
                <!-- Si la clave departamento es diferente a de la anterior fila, escribimos nombre del departamento -->
                <xsl:if test="not($key = preceding-sibling::node()/table:table-cell[5]/text:p)">     
                    <li><a>
                        <xsl:attribute name="href">#<xsl:value-of select="translate(translate($key,'.', '_'),' ', '_')"/></xsl:attribute>
                        <xsl:value-of select="$key"/>
                    </a></li>
                </xsl:if>
            </xsl:for-each>
            </ol>
        </div>
    </div>
    <div class="pie"><span class="page-number"></span></div>
    <!-- PÁGINAS CON FOTOS -->
    <!-- XSL para obtener la orla de profesores -->
   <xsl:for-each select="table:table-row[position() &gt; 1]">
        <xsl:variable name="key" select="./table:table-cell[5]/text:p" />
            <!-- Si la clave departamento es diferente a de la anterior fila, procesamos el nuevo departamento -->
            <!-- Usamos el cambio de departamento para realizar el agrupamiento (recordemos que la tabla estará ordenada) -->
            <xsl:if test="not($key = preceding-sibling::node()/table:table-cell[5]/text:p)">
                <!-- Creamos una sección que contendrá las fotos -->
                <div class="seccion">
                    <xsl:attribute name="id"><xsl:value-of select="translate(translate($key,'.', '_'),' ', '_')"/></xsl:attribute>
                    <div class="nom-seccion"><span><xsl:value-of select="$key"/></span></div>
                    <div class="wrapper">
                        <!-- Iteramos entre el personal de ese departamento, almecenado en $key -->
                        <!-- El órden del personal lo determina la tabla a procesar  -->
                        <!-- Las fotos han de estar en la carpeta img  -->
                        <xsl:for-each select="../table:table-row[position() &gt; 1 and table:table-cell/text:p = $key]">
                                <div class="personal">
                                    <img>
                                        <xsl:attribute name="src">img/<xsl:value-of select="table:table-cell[4]/text:p" /></xsl:attribute>
                                        <xsl:attribute name="onerror">this.onerror=''; this.src='no-foto.png';</xsl:attribute>
                                    </img>
                                    <div class="nom"><xsl:value-of select="table:table-cell[3]/text:p" /></div>
                                    <div><xsl:value-of select="table:table-cell[2]/text:p" /></div>       
                                </div>  
                                <!-- Hack para forzar un salto de página dentro de un departamento -->
                                <!-- grande, y que se mantenga la estructura del PDF al exportar -->
                                <!-- Fuerzo cierre de sección, añado pié y abro de nuevo sección cada 25 fotos. -->
                                <xsl:if test="position() mod 25 = 0">
                                        <xsl:text disable-output-escaping="yes"><![CDATA[</div></div>]]></xsl:text>
                                        <div class="pie"><span class="page-number"></span></div>
                                        <xsl:text disable-output-escaping="yes"><![CDATA[<div class="seccion"><div class="wrapper">]]></xsl:text>
                                </xsl:if>
                        </xsl:for-each> 
                  </div>
                </div>
                <div class="pie"><span class="page-number"></span></div>
            </xsl:if>
        </xsl:for-each>
    </xsl:template>
</xsl:stylesheet>

Sin entrar en detalles sobre como maquetar la visualización del HTML con el CSS, donde cada uno puede hacer los ajustes que considere modificando las clases que hay al principio, sí que me quiero detener en algunos aspectos interesantes para generar el PDF a partir del HTML sin problemas.

En primer lugar, se ha utilizando un @media para determinar modificaciones en la visualización en impresora (la generación del PDF se hace imprimiendo en un PDF el archivo HTML).

@media print {
  .seccion {
    min-height: 25.3cm;
    margin-top: 0px;
  }

   .pie{
    break-after: always; 
  }
 
  .personal {
    page-break-inside: avoid;
  }

  
  .page-number:before  {
        
        counter-increment: page;
        content: counter(page);

  }


}

Como puedes ver, utilizo las propiedades break-after y break-inside para forzar los saltos de página en los lugares que me interese (al final de cada departamento) o evitarlos donde no tengan que ocurrir (a mitad de una foto o datos de una persona). Además, defino el tamaño de las secciones, para forzar a que ocupen las dimensiones de un folio y también, establezco el contador de páginas.

El resultado es este:

Como se puede ver, es un documento PDF perfectamente formado, con los departamentos organizados páginas y que, además mantiene la estructura en los departamentos que tienen varias páginas (ver Familia profesional AFD).

Para conseguir esto último, he tenido que agregar un pequeño hack que forzase la incorporación de un cierre de <div> de pie de página de la siguiente manera, para evitar un error de formato: no puedo cerrar una etiqueta dentro de un <xsl:if> porque entonces el documento no estaría bien estructurado, así que introduzco ese cierre como texto literal como contenido de <xsl:text>.

  <!-- Hack para forzar un salto de página dentro de un departamento -->
<!-- grande, y que se mantenga la estructura del PDF al exportar -->
<!-- Fuerzo cierre de sección, añado pié y abro de nuevo sección cada 25 fotos. -->
<xsl:if test="position() mod 25 = 0">
    <xsl:text disable-output-escaping="yes"><![CDATA[</div></div>]]></xsl:text>
    <div class="pie"><span class="page-number"></span></div>
    <xsl:text disable-output-escaping="yes"><![CDATA[<div class="seccion"><div class="wrapper">]]></xsl:text>
 </xsl:if>

Integrando el invento en LO Calc

Solo nos faltaría integrar esta transformación como una opción más del menú de exportación de LibreOffice Calc. Básicamentre vamos a crear una nueva opción de exportación que usará nuestro xsl para realizar la transformación a HTML automáticamente desde el menú de la aplicación.

Vamos a Herramientas > Macros > Parámetros del filtro XML

Creamos uno Nuevo y ponemos los siguientes datos:

Le estamos diciendo que el filtro de exportación es para Calc y que generará un HTML.

En la pestaña «Transformación» le indicamos la ruta a nuestro XSL:

Guardamos y cerramos el Calc.

Cuando lo volvamos a abrir, el menú de exportación ya nos listará nuestra nueva opción:

Si la seleccionamos, al exportar, obtendremos en la ruta indicada en HTML con nuestro libro de fotos. Obtendremos el PDF imprimiendo a una archivo en PDF.

A partir de ahora, la generación del libro de fotos tras cualquier cambio en el hoja de cálculo es cuestión de segundos. Como dirían en una cuña promocional cualquiera: «Exportar y listo».

Conclusiones y mejoras

Esta solución supone una mejora considerable respecto a la elaboración y maquetado manual del fichero y, sobre todo, mejora especialmente la regeneración del libro en situaciones con altas y bajas constantes.

En el ejemplo os he mostrado, las fotos se enlazan con un fichero ubicado en una carpeta externa, pero se podría optimizar cambiando el contenido del campo FOTO por el código en base64 de la imagen. De ese modo, el XSL de transformación sería completamente independiente de cualquier otro fichero del sistema.

Si queréis trastear, os dejo un ZIP con los archivos que he empleado. Ya no tenéis escusa para sacar ese libro de fotos tan necesario en algunos casos.