Crear Factura o Informes con PDFMake

Esta solución es bastante distinta a todas las otras que tengo en el Blog. Es una solución JavaScript.

Trabajando en el entorno REACT que su lenguaje principal es JavaScript, estuve buscando una solución para hacer documentos PDF’s y vi  y probé PDFMake y me sorprendió estas características:

  • Simple de entender el funcionamiento y rápido en su ejecución.
  • No dispone de una herramienta de diseño, pero dispone de una solución de testeo, que sirve muy bien para diseñar y testear el informe que quieres generar.
  • Se ajusta perfectamente a dimensiones (no se desajusta como la solución de transformar HTML a PDF), por lo que puedes definir documentos formales, como albaranes, facturas, escritos oficiales, etc.
  • Se puede configurar características del PDF y del tamaño de la página
  • Se pueden definir cabeceras y pies de páginas, por lo que se puede imprimir numeración de las paginas. Gestiona, en las tablas, la finalización de una página y el encabezado de la siguiente de forma automática.
  • Dispone de más características, pero las anteriores me han parecido las más relevantes.

Objetivo

Crear facturas u otros documentos, en formato PDF (con calidad) de forma sencilla y rápida.

DEMO: https://fhumanes.com/invoice_pdfmake/Desde la opción de View, de la tabla «invoice», se accede al botón de generar el PDF.

Solución Técnica

Como he indicado, se utiliza PDFMake, que es una biblioteca JavaScript (importante, el PDF se crea en el navegador del usuario y el lenguaje de definición del informe  es JavaScript).

En esta imagen muestro cómo he distribuido los ficheros, siempre con la idea de que sea fácil de utilizar para aquellos que no son expertos en programación.

(1).- En la parte correspondiente del proyecto a los ficheros «Custom File», he creado el directorio «MyCode», para mantener la estructura de directorios y ficheros que clarifiquen la solución.

(2).- En este directorio «ajax_php» va a residir los ficheros PHP que van a producir los ficheros JSON que subirán al navegador con los datos variables que requiere el informe. Mostraré lo que tiene el caso «factura.php».

(3).- En el directorio «images», guardaremos las imágenes que deseamos se integren en el documento. En este ejemplo utilizamos «logo_oficinas.png».

(4).- Es son los ficheros de la biblioteca del producto (ahora versión 0.2.18), más el fichero «execute_report.js» que he hecho para que inicie la ejecución de la elaboración del PDF y que no habrá que cambiar si no se cambia esta estructura de ficheros. Mostraré el fichero creado.

(5).- Es el directorio «reports», donde residen los ficheros que describen el documento (en JavaScript y lenguaje PDFMake). Veremos el contenido del fichero «factura.js»

Acciones para incluir un PDF con esta solución:

  • En la página en la que vamos a poner el botón de «imprimir PDF», tenemos que incluir un «Snippet» para cargar los ficheros de JavaScript
    $html = <<<EOT
    <!-- PDFMake desde file system del proyecto -->
     <script src="./MyCode/pdfmake/pdfmake.min.js"></script>
     <script src="./MyCode/pdfmake/vfs_fonts.js"></script>
     <script src="./MyCode/pdfmake/execute_report.js"></script>
    <!-- Definición del Informe -->
     <script src="./MyCode/reports/factura.js"></script>
    EOT;
    echo $html;

    Vemos que cargamos un bloque de ficheros que serán fijos para todos los casos y el fichero específico del informe/documento que vamos a poder obtener «factura.ls».

  • En el botón de solicitar el PDF tiene esta programación:
    // Obtener la clave para que se pueda elaborar el fichero JSON
    var ctrl = Runner.getControl(pageid, 'idfactura');
    var id = ctrl.getValue();
    // console.log('El id de la factura es: ',id);
    
    var informe = 'factura';   // Nombre del documento que oincidira con el nombre del fichero del directorio "ajax_php" y del directorio "reports"
    
    pedirJsonInforme(informe,id); // Ejecuta el Informe
    
    return false;

Contenido de los ficheros principales:

  1. Fichero «execute_report.js«:
    // Función para solicitar el fichero JSON de los datos del Informe
    // Parámetros: "Nombre de informe y ID, key del dato a obtener
    
    function pedirJsonInforme(informe,id) { 
    
        // 1. Hacemos petición AJAX al servidor (ej: API REST)
        $.ajax({
            type: 'POST',                                   //  SEND with POST method
            url: './MyCode/ajax_php/'+informe+'.php',           // Destination file (the PHP that manages the data)
            async: false,                                   // We do a synchronous operation
            data: { ID: id },                               // Data that are sent
            
            success: function(data) {
                Swal.fire({
                	 // icon: 'info',
                	 title: 'El Informe se ha descargado',
                	 text: '',
                	 imageUrl: "MyCode/images/download.gif",
                	 imageHeight: 200,
                     imageAlt: "Estamos trabajando",
                	 timer: 4000,
                	 timerProgressBar: true,
                	 toast: true,
                	 showConfirmButton: false,
                	 position:  'center', // "top-start",
                	 footer: ''
                	});
                // 2. Si la petición es exitosa, generamos el PDF con los datos
                // console.log("Datos Recibidos: ",resp);
                // console.log("Identificador: ",id);
                generarPDFConDatos(data,id);                   // Generando el PDF
            },
            
            error: function(error) {
                console.error("Error al obtener datos:", error);
                alert("¡Error al crear JSON de los datos!");
            }
        });
    }
  2. Fichero «factura.php«:
    <?php
    
    require_once("../../include/dbcommon.php"); // DataBase PHPRunner
    
    if (!isset($_POST['ID'])) { //  Control of the call and verification of the existence of the parameter
      echo("Please provide a Valor.");
      die();
    }
    $id = $_POST['ID'];
    
    // Datos de cabecera de factura 
    $sql = 
    "SELECT
    idfactura, cliente_idcliente, Nif, NombreRazonSocial,
    Domicilio, RestoDomicilio, FechaFactura, TotalFactura, Email
    FROM factura
    WHERE idfactura = $id   
    ";
    $rs = DB::Query($sql);
    $invoice = $rs->fetchAssoc();
    
    // Datos de líneas de Factura
    $sql = 
    "SELECT Nombre, Precio, Cantidad, Valor
    FROM linea_factura
    WHERE  factura_idfactura = $id
    ";
    $lines = array();
    $rs = DB::Query($sql);
     
    while( $line = $rs->fetchAssoc() )
    {
    $lines[]= $line;
    }
    
    $data = array();
    $data['invoice'] = $invoice;
    $data['lines'] = $lines;
    
    
    $json = json_encode($data,JSON_NUMERIC_CHECK);
    
    header_remove('X-Powered-By'); // Opcional: elimina el header de PHP
    header('Cache-Control: no-cache, must-revalidate');
    header('Expires: Mon, 26 Jul 1997 05:00:00 GMT');
    header('Content-Type: application/json; charset=utf-8');
    
    echo $json;

    Muy importantes las líneas 42-46, para que cuando llegue el fichero JSON lo entienda como fichero JSON. Si no se hace esto, la información la entenderá como «string»

  3. Fichero «factura.js«:
    // Función que genera el PDF con los datos obtenidos
    function generarPDFConDatos(datos,id) {
    
    // Configuration partition of URL of access to images
    const url = new URL(window.location.href);
    // console.log("URL de ejecución:", url);
    if ( url.hostname == "localhost") {
        var config_url = 'http://localhost/invoice_pdfmake/';
    } else {
        var config_url = 'https://fhumanes.com/invoice_pdfmake/';
    }
    
    
    // playground requires you to assign document definition to a variable called dd
    let decimal = new Intl.NumberFormat('es-ES', {
        style: 'decimal',
        // currency: 'EUR',
        minimumFractionDigits: 2,
        maximumFractionDigits: 2,
      useGrouping: true // Esto fuerza el separador de miles
    });
    
    // Formateador de fechas para valores MySQL
    let fechaFormato = new Intl.DateTimeFormat('es-ES', {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
        // hour: '2-digit',    // descomenta si necesitas hora
        // minute: '2-digit', // descomenta si necesitas hora
        // second: '2-digit',  // descomenta si necesitas hora
        // hour12: false       // usa formato 24h si necesitas hora
    });
    
    // Función para formatear fechas desde MySQL
    function formatearFechaMySQL(fechaMySQL) {
        // Convertir la fecha MySQL a objeto Date
        const fecha = new Date(fechaMySQL);
        // Verificar si la conversión fue válida
        if (isNaN(fecha.getTime())) {
            console.error('Fecha inválida:', fechaMySQL);
            return 'Fecha inválida';
        }
        return fechaFormato.format(fecha);
    }
    
    // Definición del Informe 
    var dd =  
    {
        // a string or { width: number, height: number }
        pageSize: 'A5',
        // by default we use portrait, you can change it to landscape if you wish
        pageOrientation: 'landscape',
        // [left, top, right, bottom] or [horizontal, vertical] or just a number for equal margins
        pageMargins: [ 50, 30, 20, 40 ],
        
        footer: function(currentPage, pageCount) 
        { 
            return [
                {text: 'Página: '+currentPage.toString() + ' de ' + pageCount ,margin: [30,10,10,0],alignment: 'right' }
                ]
        },
        header: function(currentPage, pageCount, pageSize) 
        {
            // you can apply any logic and return any valid pdfmake element
            return [
            { text: ' ',margin: [30,10,30,10], alignment: (currentPage % 2) ? 'left' : 'right' },
            { canvas: [ { type: 'rect', x: 170, y: 32, w: pageSize.width - 170, h: 40 } ] }
            ]
        },
            
        content: 
        [
          {
              layout: 'noBorders', // optional
              table: {
                widths: ['auto', '*', 'auto'],
                body: 
                [ 
                    [ 
                      { image: 'logo' ,
                          width: 75 },
                      { text: '\nFACTURA/INVOICE', style: 'header' },
                      { text: `\nNúmero: ${datos.invoice.idfactura}`, margin: [0, 10], color: 'blue'},
                    ]
                ]
              },
          }, 
          { text: '\n\n' },
          { text: `Fecha: ${formatearFechaMySQL(datos.invoice.FechaFactura)}` },
          { text: `Cliente: ${datos.invoice.NombreRazonSocial}` },
          { text: `NIF: ${datos.invoice.Nif}`, margin: [0, 0, 0, 20] },
          
          {
            layout: 'lightHorizontalLines', // optional
            table: {
              headerRows: 1,
              widths: ['*', 'auto', 'auto', 'auto'],
              body: [
                  [
                      { text: 'Descripción', style: 'tableHeader' },
                      { text: 'Cantidad', style: 'tableHeader', alignment: 'center' },
                      { text: 'Precio', style: 'tableHeader', alignment: 'right' },
                      { text: 'Total', style: 'tableHeader', alignment: 'right' }
                  ],
                ...datos.lines.map(item => 
                  [
                      item.Nombre,
                      { text: item.Cantidad ,alignment: 'center'},
                      { text: decimal.format(item.Precio),alignment: 'right'} ,
                      { text: decimal.format(item.Valor),alignment: 'right'} ,
                  ]), 
                    // Asegúrate de que cada fila tenga exactamente 4 elementos:
                  [{ text: 'Total:', alignment: 'right', colSpan: 3, bold: true },'', '',  { text: decimal.format(datos.invoice.TotalFactura),alignment: 'right', bold: true}],
          
              ],
            },
          }, 
        ],
        images: 
        {
            logo: config_url+'MyCode/images/logo_oficinas.png',
        },
        styles: {
            header: { 
                fontSize: 18, 
                font: 'Roboto', 
                bold: true,
                alignment: 'center' },
            tableHeader: {
                font: 'Roboto', 
                bold: true,
                fontSize: 13,
                fillColor: '#dddddd'
            } 
        },
    };
    pdfMake.createPdf(dd).download('factura_'+id+'.pdf'); // Descarga directa
    };

    En este fichero hay definidas varias partes. De línea 4 a 44, se definen funciones de JavaScript para facilitar formatos numéricos y de fechas, principalmente, aunque  de línea 4 -11, lo que hace es saber en que host está ejecutándose para establecer el dominio de la carga de las imágenes.

    De 46-136, está la definición del informe que vamos a poder probar en la URL http://pdfmake.org/playground.html, donde deberemos cargar el contenido desde la línea 4-136 y para que disponga de los datos que va a disponer en nuestro programa, añadimos este contenido:

    var id = 12;     // Valor de la KEY de acceso a los datos
    // Ejemplo del fichero JSON que se va a obtener
    var datos =      
    {
      "invoice": {
        "idfactura": 12,
        "cliente_idcliente": 1,
        "Nif": "00000003M",
        "NombreRazonSocial": "Cliente 1",
        "Domicilio": "C/ del Pino Verde, 1 áéíúó ñ",
        "RestoDomicilio": "2045 Madrid",
        "FechaFactura": "2024-12-17",
        "TotalFactura": 67.1,
        "Email": "[email protected]"
      },
      "lines": [
        {
          "Nombre": "Artículo 1",
          "Precio": 5.21,
          "Cantidad": 10,
          "Valor": 52.1
        },
        {
          "Nombre": "Artículo 3",
          "Precio": 3,
          "Cantidad": 5,
          "Valor": 15
        }
      ]
    };
     // -o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-o-

    Ahora sólo quedaría el «problema» de las referencias/URL de las imágenes. Como se está ejecutando fuera de la plataforma, no puede acceder a la imagen y aquí hay 2 soluciones:

    – Comentar la utilización de las imágenes y la definición de las mismas.
    – Definir la imágenes en formato Base 64

    Al final, la pantalla de diseño debería tener este aspecto.

    Y desde aquí, ya podremos ir revisando información (documentación y Ejemplos) y hacer pruebas de ajustes de visualización del PDF.

Aún teniendo dificultades para aquellos que no disponen de muchos conocimientos en la codificación, creo que diseñar informes de esta forma no es excesivamente complejo y puede dotar a los desarrollos de «informes de calidad en PDF«. Espero que os guste.

Os dejo los fuentes del ejemplo para que lo podáis descargar a vuestros PC’s y ajustar/probar la solución.

Adjuntos

Archivo Tamaño de archivo Descargas
zip PHPRunner 10.91 + backup BD 2 MB 25

Blog personal para facilitar soporte gratuito a usuarios de React y PHPRunner