Guía R-006 – Recogida de la firma manuscrita – Signature-pad

En la actualidad, muchas de las aplicaciones que se hacen para el móvil tienen que ver con el trabajo exterior a la empresas y casi siempre es relacionado con el Cliente, de tal forma que es muy habitual la recogida de la firma del Cliente, como garantía de la aceptación del servicio.

En este caso, el ejemplo se basa en mostrar lo sencillo que es la recepción de la firma (Cliente, Técnico, etc.) en esta plataforma de React.

Objetivo

Mostrar un ejemplo básico de una aplicación realizada en React para la captura de la firma manuscrita del (Cliente, Técnico, etc.)

DEMO: https://fhumanes.com/signature-test/

(1)  (2)  (3)  Pasos a seguir en el ejemplo

He hecho un programa PHPRunner para que podáis comprobar que el contenido está almacenado en la base de datos. https://fhumanes.com/signature-php

Solución Técnica

He querido dejar muy, muy simple el ejemplo para que podáis ver lo sencillo que es trabajar en esta plataforma.

La firma se recoge en formato PNG y se envía al server como un campo más de un formulario en formato «Base 64».  Como son imágenes pequeñas, no hay ningún problema en utilizar este sistema de transformación a base 64.

El código más relevante de React es el fichero App.js:

import React, { useRef, useState } from 'react';
import SignatureCanvas from 'react-signature-canvas';
import axios from 'axios';

import Config from './config';

// Función para recortar manualmente el canvas
const trimCanvas = (canvas) => {
    const ctx = canvas.getContext('2d');
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    const data = imageData.data;
    
    let top = 0, bottom = canvas.height, left = 0, right = canvas.width;
    
    // Encontrar el borde superior
    topLoop: for (; top < bottom; top++) {
      for (let x = 0; x < right; x++) {
        const alpha = data[(top * canvas.width + x) * 4 + 3];
        if (alpha > 0) break topLoop;
      }
    }
    
    // Encontrar el borde inferior
    bottomLoop: for (; bottom > top; bottom--) {
      for (let x = 0; x < right; x++) {
        const alpha = data[((bottom - 1) * canvas.width + x) * 4 + 3];
        if (alpha > 0) break bottomLoop;
      }
    }
    
    // Encontrar el borde izquierdo
    leftLoop: for (; left < right; left++) {
      for (let y = top; y < bottom; y++) {
        const alpha = data[(y * canvas.width + left) * 4 + 3];
        if (alpha > 0) break leftLoop;
      }
    }
    
    // Encontrar el borde derecho
    rightLoop: for (; right > left; right--) {
      for (let y = top; y < bottom; y++) {
        const alpha = data[(y * canvas.width + (right - 1)) * 4 + 3];
        if (alpha > 0) break rightLoop;
      }
    }
    
    // Crear nuevo canvas con las dimensiones recortadas
    const trimmedCanvas = document.createElement('canvas');
    trimmedCanvas.width = right - left;
    trimmedCanvas.height = bottom - top;
    const trimmedCtx = trimmedCanvas.getContext('2d');
    
    trimmedCtx.drawImage(
      canvas,
      left, top, right - left, bottom - top,
      0, 0, right - left, bottom - top
    );
    
    return trimmedCanvas;
  };

const SignaturePad = () => {
  const sigCanvas = useRef(null);
  const [imageURL, setImageURL] = useState(null);
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [submitMessage, setSubmitMessage] = useState('');

  // Limpiar el canvas de firma
  const clearSignature = () => {
    sigCanvas.current.clear();
    setImageURL(null);
    setSubmitMessage('');
  };

  // Guardar la firma como imagen PNG en Base64
  /*
  // Reduciendo el tamaño de la imagen
  const saveSignature = () => {
    if (sigCanvas.current.isEmpty()) {
      alert('Por favor, proporciona tu firma primero');
      return;
    }

    const canvas = sigCanvas.current.getCanvas();
    const trimmedCanvas = trimCanvas(canvas);
    const image = trimmedCanvas.toDataURL('image/png');
    
    setImageURL(image);
    setSubmitMessage('Firma guardada y recortada, lista para enviar');
  };
  */
  
  const saveSignature = () => {
    if (sigCanvas.current.isEmpty()) {
      alert('Por favor, proporciona tu firma primero');
      return;
    }
  
    // Enfoque alternativo
    const canvas = sigCanvas.current.getCanvas();
    const image = canvas.toDataURL('image/png');
    setImageURL(image);
    setSubmitMessage('Firma guardada, lista para enviar');
  };
  

  // Enviar la firma al servidor
  const submitSignature = async () => {
    if (!imageURL) {
      alert('Por favor, guarda tu firma primero');
      return;
    }

    setIsSubmitting(true);
    setSubmitMessage('Enviando firma...');

    try {
      // Extraer solo la parte Base64 de la URL de datos
      const base64Image = imageURL.split(',')[1];

      const url = Config.RUTA_API + "/saveFirma";
      const formData = new FormData();
        formData.append('signature', base64Image);
        formData.append('timestamp',  new Date().toISOString());

      const config = {
        headers: {
            "Content-Type": "application/x-www-form-urlencoded",
            "Authorization": Config.AUTHORIZATION
        },
      };

      // Reemplaza esta URL con tu endpoint real
      const response = await axios.post(
        url,
        formData,
        config
      );

      setSubmitMessage('Firma enviada con éxito!');
      console.log('Respuesta del servidor:', response.data);
    } catch (error) {
      console.error('Error al enviar la firma:', error);
      setSubmitMessage('Error al enviar la firma');
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <div style={styles.container}>
      <h1>Captura de Firma</h1>
      
      <div style={styles.signatureContainer}>
        <SignatureCanvas
          ref={sigCanvas}
          canvasProps={{
            width: 500,
            height: 200,
            className: 'signature-canvas',
            style: styles.canvas
          }}
          penColor="black"
        />
      </div>

      <div style={styles.buttonGroup}>
        <button style={styles.button} onClick={clearSignature}>
          Limpiar Firma
        </button>
        <button style={styles.button} onClick={saveSignature}>
          Guardar Firma
        </button>
        <button 
          style={styles.button} 
          onClick={submitSignature}
          disabled={isSubmitting || !imageURL}
        >
          {isSubmitting ? 'Enviando...' : 'Enviar Firma'}
        </button>
      </div>

      {submitMessage && (
        <p style={submitMessage.includes('éxito') ? styles.success : styles.message}>
          {submitMessage}
        </p>
      )}

      {imageURL && (
        <div style={styles.preview}>
          <h3>Previsualización de la firma:</h3>
          <img 
            src={imageURL} 
            alt="Firma guardada" 
            style={styles.previewImage}
          />
        </div>
      )}
    </div>
  );
};

// Estilos
const styles = {
  container: {
    maxWidth: '600px',
    margin: '0 auto',
    padding: '20px',
    fontFamily: 'Arial, sans-serif',
    textAlign: 'center',
  },
  signatureContainer: {
    border: '1px solid #ccc',
    borderRadius: '4px',
    margin: '20px 0',
    backgroundColor: '#f9f9f9',
  },
  canvas: {
    backgroundColor: '#f9f9f9',
  },
  buttonGroup: {
    display: 'flex',
    justifyContent: 'center',
    gap: '10px',
    margin: '20px 0',
  },
  button: {
    padding: '10px 15px',
    backgroundColor: '#4CAF50',
    color: 'white',
    border: 'none',
    borderRadius: '4px',
    cursor: 'pointer',
    fontSize: '16px',
  },
  preview: {
    marginTop: '30px',
  },
  previewImage: {
    border: '1px solid #ddd',
    borderRadius: '4px',
    padding: '5px',
    maxWidth: '100%',
  },
  message: {
    color: '#666',
    margin: '10px 0',
  },
  success: {
    color: 'green',
    margin: '10px 0',
    fontWeight: 'bold',
  },
};

export default SignaturePad;

El ejemplo que recoge los datos está hecho en PHP (sin integrar con PHPRunner), es decir, «puro» PHP.

La clase que gestiona los accesos a la base de datos es «Dbsignature.php»:

<?php

// include_once '../include/Config.php';  // Configuration Rest Api

/**
 *
 * @About:      Gestión de Datos de Gestión de firmas
 * @File:       Dbsignature.php
 * @Date:       $Date:$ April 2025
 * @Version:    $Rev:$ 1.0
 * @Developer:  fernando humanes
 **/

    use Psr\Http\Message\ResponseInterface as Response;
    use Psr\Http\Message\ServerRequestInterface as Request;
    use Selective\BasePath\BasePathMiddleware;
    use Slim\Factory\AppFactory;

class Dbsignature
{

    private $connDB;

    function __construct()
    {
        /* You should enable error reporting for mysqli before attempting to make a connection */
        mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
        $mysqli = new mysqli(DB_HOST, DB_USER, DB_PASSWORD, DB_NAME);
        /* Set the desired charset after establishing a connection */
        $mysqli->set_charset('utf8mb4');
        
        $this->connDB = $mysqli;      // No se usa esta conexión si está integrado con PHPRunner
    }
    
    private function responseKO ($data_arr) {
        global $errorMessages;
        $data_arr['error'] = true;
        $data_arr["message_num"] = '005';
        $data_arr["message"] = str_replace("?",$this->connDB->error,$errorMessages['005']);
        $this->connDB->close();
        return $data_arr;
    }
    private function responseOK ($data_arr) {
        global $errorMessages;
        $data_arr['error'] = false;
        $data_arr["message_num"] = '006';
        $data_arr["message"] = $errorMessages['006'];
        $this->connDB->close();
        return $data_arr;
    }
    /*
    * ADD de Firmas
    */
    public function addSignature($param, Request $request,Response $response)
    {
        global $errorMessages;
                
        $signature = $param['signature'];
        $timestamp = $param['timestamp'];
        // Decodificar la cadena Base64
        $imageData = base64_decode($signature);
        
        $filename = uniqid('firma_') . '.png';
        
        // Convesión de fecha y adaptación a zona Horaria
        $dt_obj = new DateTime($timestamp ." UTC");                 // La fecha la recibimos en formato UTC
        $dt_obj->setTimezone(new DateTimeZone('Europe/Madrid'));    // La adaptamos al formato del servidor
        $timestamp = date_format($dt_obj, 'Y-m-d H:i:s');
   
        $sql = "
        INSERT INTO signature_react (`file`, `nameFile`, `creationDate`)
        VALUES (?,?,?)";
        
        $data_arr = array();
        $stmt = $this->connDB->prepare($sql);

        $stmt->bind_param('bss',$imageData, $filename, $timestamp);
        // Para enviar el BLOB en bloques (útil para archivos grandes)
        $stmt->send_long_data(0, $imageData);
        
        $stmt->execute();  
        
        return $this->responseOK($data_arr);     
    }
}

Como siempre, os dejo los fuentes para que podáis configurarlos en vuestro PC y ajustéis lo que necesitéis.

Adjuntos

Archivo Tamaño de archivo Descargas
zip signature-server (PHP) 221 KB 26
zip signature-test (REACT) 177 KB 29

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