// @vitest-environment jsdom // Regresión del importador de PDF. Cubre las dos capas donde ha fallado: // // 1. La DECODIFICACIÓN del texto (pdf.js). Un informe real de laboratorio no se pudo importar // —«No se encontraron datos reconocibles en el PDF»— porque su CMap `ToUnicode` declara // destinos de UN byte (fuera de especificación, pero lo emiten productores reales tipo // Ghostscript) y pdf.js 3.11 los leía de dos en dos: cada carácter salía desplazado 8 bits, // 'H' (0x48) como U+4800. Aquí se construye un PDF con esa misma anomalía y se fija que la // versión de pdf.js que vendorizamos lo decodifica bien. // // 2. El RECONOCIMIENTO sobre el texto ya decodificado (parsearTextoLab / parsearTextoPaciente). // // El texto de las pruebas de la capa 2 reproduce la MAQUETA del informe real (dot leaders, // cabecera de sección + línea RESULTADO, párrafos interpretativos) con datos inventados: el // informe original lleva nombre de propietario, clínica, dirección y nº de chip, y no tiene por // qué entrar en el repositorio para fijar una regresión de parseo. import { describe, it, expect } from 'vitest'; import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'; import { parsearTextoLab, parsearTextoPaciente } from '../src/pdf-parser.js'; // --- Capa 1: decodificación ----------------------------------------------------------------- /** PDF mínimo cuyo `ToUnicode` usa destinos de un byte (`<48><48><48>` en vez de `<48><48><0048>`). */ function pdfConToUnicodeDefectuoso(lineas: string[]): Uint8Array { const usados = [...new Set(lineas.join('').split(''))].filter((c) => c !== ' '); const bfchar = usados .map((c) => { const hex = c.charCodeAt(0).toString(16).padStart(2, '0'); return `<${hex}><${hex}>`; // ← destino de UN byte: la anomalía que se quiere reproducir }) .join('\n'); const cmap = `/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def 1 begincodespacerange <00> endcodespacerange ${usados.length} beginbfchar ${bfchar} endbfchar endcmap CMapName currentdict /CMap defineresource pop end end`; const escapar = (s: string) => s.replace(/[\\()]/g, (c) => `\\${c}`); const contenido = `BT /F1 10 Tf 40 750 Td 14 TL\n${lineas .map((l) => `(${escapar(l)}) Tj T*`) .join('\n')}\nET`; const objetos = [ '<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] ' + '/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>', '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /ToUnicode 6 0 R >>', `<< /Length ${contenido.length} >>\nstream\n${contenido}\nendstream`, `<< /Length ${cmap.length} >>\nstream\n${cmap}\nendstream`, ]; let pdf = '%PDF-1.4\n'; const offsets: number[] = []; objetos.forEach((cuerpo, i) => { offsets.push(pdf.length); pdf += `${i + 1} 0 obj\n${cuerpo}\nendobj\n`; }); const inicioXref = pdf.length; pdf += `xref\n0 ${objetos.length + 1}\n0000000000 65535 f \n`; for (const off of offsets) pdf += `${String(off).padStart(10, '0')} 00000 n \n`; pdf += `trailer\n<< /Size ${objetos.length + 1} /Root 1 0 R >>\nstartxref\n${inicioXref}\n%%EOF\n`; return Uint8Array.from(pdf, (c) => c.charCodeAt(0)); } async function extraer(datos: Uint8Array): Promise { const doc = await pdfjs.getDocument({ data: datos }).promise; const paginas: string[] = []; for (let p = 1; p <= doc.numPages; p++) { const contenido = await (await doc.getPage(p)).getTextContent(); // Mismo ensamblado que `extraerTextoPdf` en src/pdf-parser.ts. paginas.push( contenido.items .map((i) => ('str' in i ? i.str + (i.hasEOL ? '\n' : ' ') : '')) .join(''), ); } return paginas.join('\n'); } describe('decodificación de PDF con ToUnicode defectuoso', () => { it('lee los caracteres sin desplazarlos 8 bits', async () => { const texto = await extraer( pdfConToUnicodeDefectuoso(['HEMATOCRITO...... 48,1 %', 'ALT-GPT......... 260 U/L']), ); expect(texto).toContain('HEMATOCRITO'); expect(texto).toContain('ALT-GPT'); // El síntoma exacto de la regresión: 'H' saliendo como U+4800 y ',' como U+2C00. expect(texto).not.toContain('䠀'); expect(texto).not.toContain('Ⰰ'); }); it('conserva los ceros (el 0 desplazado caía en U+3000 y se normalizaba a espacio)', async () => { const texto = await extraer(pdfConToUnicodeDefectuoso(['ALT-GPT......... 260 U/L'])); // Este es el motivo por el que el desplazamiento NO se repara a posteriori: perder los ceros // convierte una ALT de 260 U/L en 26, que es un resultado normal. expect(texto).toContain('260'); expect(parsearTextoLab(texto).alt).toBe(260); }); }); // --- Capa 2: reconocimiento ----------------------------------------------------------------- // Maqueta del informe: etiqueta + líderes de puntos con el valor en la misma línea (hemograma) o // cabecera de sección con el valor en una línea RESULTADO posterior (bioquímica). const INFORME = `Datos personales LUNA Perra, Canis lupus familiaris PRUEBA RESULTADO UNIDADES VAL.DE REFERENCIA HEMOGRAMA Contaje y Fórmula Electrónico HEMATOCRITO...................... 48,1 % (37,0-55,0) HEMOGLOBINA...................... 17,7 g/dL (12,0-20,0) HEMATÍES......................... 7.300.000 /µL (5.100.000-8.000.000) PLAQUETAS........................ 171.000 /µL (200.000-500.000) FÓRMULA LEUCOCITARIA LEUCOCITOS....................... 7.510 /µL (4.900-21.700) EOSINÓFILOS...................... 5 % BASÓFILOS........................ 0 % LINFOCITOS....................... 29 % SEGMENTADOS...................... 59 % UREA / SUERO Química seca - Espectrofotometría Ultravioleta-Visible RESULTADO........................ 67 mg/dL (11-53) CREATININA / SUERO Química seca - Espectrofotometría Ultravioleta-Visible RESULTADO........................ 1,73 mg/dL (0,50-1,60) PROTEÍNAS TOTALES / SUERO Química seca - Espectrofotometría Ultravioleta-Visible RESULTADO........................ 76 g/L (47-68) COCIENTE ALBÚMINA / GLOBULINA Química seca - Espectrofotometría Ultravioleta-Visible ALBUMINA......................... 40 g/L (24-40) GLOBULINAS TOTALES............... 36,00 g/L (21,00-35,00) RESULTADO........................ 1,11 (0,70-1,50) ALT-GPT Química seca - Espectrofotometría Ultravioleta-Visible RESULTADO........................ 260 U/L (21-79) SDMA (Dimetilarginina simétrica) Inmunoturbidimetría RESULTADO........................ 12,6 µg/dL (<15,0) Interpretación: El aumento de SDMA en suero o plasma es un indicador temprano de reducción de la funcionalidad renal. IRIS recomienda valorar los resultados de SDMA junto a otros parámetros; entre ellos UPC, Creatinina, presión arterial y densidad urinaria. CREATINKINASA Química seca - Espectrofotometría Ultravioleta-Visible RESULTADO........................ 90 U/L (<300) `; describe('parsearTextoLab sobre la maqueta de informe español', () => { const r = parsearTextoLab(INFORME); it('lee los valores de la misma línea', () => { expect(r.hct).toBe(48.1); expect(r.hgb).toBe(17.7); expect(r.wbc).toBe(7.51); expect(r.plt).toBe(171); }); it('lee los valores que van en la línea RESULTADO bajo su cabecera', () => { expect(r.creat).toBe(1.73); expect(r.alt).toBe(260); expect(r.sdma).toBe(12.6); expect(r.prot).toBe(7.6); // 76 g/L → g/dL expect(r.bun).toBeCloseTo(31.29, 1); // urea 67 mg/dL → BUN }); it('reconoce los nombres acentuados en mayúsculas', () => { expect(r.rbc).toBe(7.3); // HEMATÍES expect(r.eosino).toBe(5); // EOSINÓFILOS expect(r.linfo).toBe(29); }); it('reconoce SEGMENTADOS como neutrófilos', () => { expect(r.neutro).toBe(59); }); it('conserva un 0 legítimo de la fórmula leucocitaria', () => { expect(r.baso).toBe(0); }); it('no confunde el analito con el cociente calculado a partir de él', () => { // Bajo «COCIENTE ALBÚMINA / GLOBULINA» la línea RESULTADO es el cociente A/G (1,11); la // albúmina y las globulinas están en sus propias líneas, encima. expect(r.alb).toBe(4); // 40 g/L → g/dL expect(r.glob).toBe(3.6); // 36 g/L → g/dL }); it('no inventa un analito citado de pasada en un párrafo interpretativo', () => { // «densidad urinaria» aparece en la interpretación del SDMA. El valor más cercano es el de // la CREATINKINASA, dos líneas más abajo: importarlo daría una densidad urinaria de 90 en // una analítica que no incluye orina. expect(r.usg).toBeUndefined(); }); }); describe('parsearTextoPaciente', () => { it('deduce especie y sexo del nombre científico y del género gramatical', () => { expect(parsearTextoPaciente(INFORME)).toMatchObject({ especie: 'Canino', sexo: 'Hembra' }); }); it('la etiqueta explícita manda sobre la deducción', () => { const p = parsearTextoPaciente('Especie: Felino\nSexo: Macho\nEl perro del vecino'); expect(p).toMatchObject({ especie: 'Felino', sexo: 'Macho' }); }); });