En un almacén médico, cada caja trae más información de la que parece: no solo un identificador de producto, sino lote y fecha de caducidad codificados en el mismo símbolo. Leer eso desde el navegador — sin pedirle al usuario que instale una app nativa — es el reto que resolví en Kezelmedica, un sistema de control de inventario construido como PWA (FastAPI + PostgreSQL en el backend, JavaScript vanilla en el frontend).
El reto técnico: un mismo código, dos formatos, navegadores distintos
Los códigos de barras GS1-128 y los símbolos DataMatrix no guardan un solo dato — codifican varios campos concatenados usando Identificadores de Aplicación (AI): un prefijo numérico entre paréntesis que le dice al lector qué representa cada bloque de dígitos que sigue. Por ejemplo, (01) es el GTIN del producto, (10) es el lote y (17) es la fecha de caducidad en formato AAMMDD.
El segundo problema es de plataforma: Chrome en Android trae una API nativa para decodificar estos símbolos directamente desde la cámara (BarcodeDetector), rapidísima y sin dependencias. iOS Safari y Firefox, en cambio, no la implementan — ahí hay que decodificar el video cuadro por cuadro con una librería en JavaScript.
La arquitectura: motor nativo primero, librería de respaldo solo si hace falta
La solución no es cargar una librería de escaneo pesada para todos los usuarios "por si acaso". Es detectar qué soporta el navegador y cargar el motor de respaldo únicamente cuando el nativo no está disponible:
// Esqueleto simplificado del flujo real
class BarcodeScanner {
async start(video) {
if ("BarcodeDetector" in window) {
// Chrome/Android: motor nativo, el más rápido
await this._runNative(video);
} else {
// iOS Safari / Firefox: no hay BarcodeDetector,
// se carga ZXing bajo demanda y se decodifica el
Ese "bajo demanda" importa más de lo que parece: ZXing pesa cerca de 1.2 MB minificado. Cargarlo siempre, incluso en el celular Android que ya trae un decodificador nativo integrado en el sistema operativo, sería descargar más de un megabyte que ese usuario nunca va a usar. El <script> de ZXing solo se inyecta dinámicamente cuando la detección de BarcodeDetector falla.
Un detalle que no es obvio hasta que lo pisas: el bug del canvas en iOS
ZXing no lee el <video> directamente — dibuja cada cuadro en un <canvas> oculto y decodifica esa imagen (HTMLCanvasElementLuminanceSource → HybridBinarizer → BinaryBitmap). En iOS Safari, si se reutiliza el mismo canvas entre sesiones de escaneo, el canvas interno de la librería puede quedar atascado en 0×0 píxeles la segunda vez que el usuario abre la cámara — un bug silencioso que solo aparece al reabrir el escáner, no en la primera prueba. La solución fue tratar cada sesión de escaneo como un contexto nuevo en vez de reciclar el mismo canvas.
Este tipo de detalle es exactamente el motivo por el que "funciona en mi Android" no es suficiente para una PWA de uso real en almacén: hay que probar en el dispositivo más restrictivo, no en el más cómodo.
Extraer lote y caducidad del texto escaneado
Una vez decodificado el símbolo (por cualquiera de los dos motores), el resultado es un string crudo con los AI concatenados. El parser tiene que saber qué AI son de longitud fija y cuáles son variables — estas últimas terminan con un separador de grupo (\x1D, el carácter GS) o con el final del string:
// Esqueleto simplificado de parseGS1()
const AI_FIXED = {
"01": 14, // GTIN
"17": 6, // Caducidad (AAMMDD)
"10": null, // Lote: longitud variable
};
function parseGS1(raw) {
const resultado = { gtin: null, lote: null, caducidad: null };
let i = 0;
while (i < raw.length) {
const ai = raw.slice(i, i + 2);
i += 2;
if (ai === "01") {
resultado.gtin = raw.slice(i, i + 14).replace(/^0+/, "");
i += 14;
} else if (ai === "17") {
const yymmdd = raw.slice(i, i + 6);
resultado.caducidad = formatearFecha(yymmdd); // AAMMDD -> AAAA-MM-DD
i += 6;
} else if (ai === "10") {
const fin = raw.indexOf("\x1D", i);
resultado.lote = raw.slice(i, fin === -1 ? undefined : fin);
i = fin === -1 ? raw.length : fin + 1;
} else {
break; // AI no reconocido, se detiene el parseo
}
}
return resultado;
}
Con lote y caducidad ya extraídos, el formulario de movimiento de inventario se autocompleta sin que el usuario tenga que teclear nada — y si el código no trae esos AI (por ejemplo, un código de barras simple sin GS1), el sistema simplemente usa el string completo para buscar el producto por su identificador.
El resultado práctico: un auxiliar de almacén escanea una caja con la cámara del celular, sin instalar nada, y el sistema ya sabe qué producto es, en qué lote y cuándo caduca — funcione en un Android de gama media o en un iPhone con Safari.