Spaces:
Sleeping
Sleeping
File size: 9,179 Bytes
c0b119e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 | // @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><ff>
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<string> {
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' });
});
});
|