// Extracción de texto de PDF y parseo de valores de laboratorio en el cliente // (ningún dato sale del navegador). Puerto TS de js/pdf-parser.js. import { aplicarValoresAFormulario, aplicarPacienteAFormulario, mostrarToast } from './form-inject.js'; import { manejadorAsync } from './async.js'; const PDFJS_WORKER = 'assets/lib/pdfjs/pdf.worker.min.js'; interface AnalitoDef { campo: string; re: RegExp; claveConv?: string } interface ConversionRegla { re: RegExp; factor: number | ((v: number) => number) } type Resultados = Record; interface PdfjsLib { GlobalWorkerOptions: { workerSrc: string }; getDocument(opts: { data: ArrayBuffer }): { promise: Promise }; } interface PdfDoc { numPages: number; getPage(n: number): Promise } interface PdfPage { getTextContent(): Promise<{ items: Array<{ str: string; hasEOL?: boolean }> }> } const DEFS_ANALITOS: AnalitoDef[] = [ // Hematología: Serie Roja { campo: 'rbc', re: /\b(?:eritrocit\w*|gl[oó]bulos?\s+rojos?|r\.?b\.?c\.?|eri)\b/i }, { campo: 'hgb', re: /\b(?:hemoglobin[ao]?\w*|hgb|hb)\b(?!a\d)/i }, { campo: 'hct', re: /\b(?:hematocrit[oo]?\w*|hct|pcv)\b/i }, { campo: 'vcm', re: /\b(?:v\.?c\.?m\.?|m\.?c\.?v\.?|vol(?:umen)?\s+corp\w*)\b/i }, // CHCM debe ir antes que HCM para evitar que MCH coincida con MCHC al usar lookahead negativo { campo: 'chcm', re: /\b(?:c\.?h\.?c\.?m\.?|m\.?c\.?h\.?c\.?|concentr\w+\s+hem\w+\s+corp\w*)\b/i }, { campo: 'hcm', re: /\b(?:h\.?c\.?m\.?|m\.?c\.?h\.?)(?![cC]\.?)\b/i }, { campo: 'rdw', re: /\b(?:r\.?d\.?w\.?(?:-cv)?|anch\w+\s+distrib\w+)\b/i }, { campo: 'reti', re: /\b(?:reti\w*\s*%|ret\.?\s*%|ret[eé])\b/i }, { campo: 'reti_abs', re: /\b(?:reti\w*\s*#|ret\.?\s*#)\b/i }, { campo: 'nrbc', re: /\b(?:n\.?r\.?b\.?c\.?|eritrocit\w+\s+nucle\w+|nucleat\w+\s+r\.?b\.?c\.?|nrbc)\b/i }, // Hematología: Serie Blanca { campo: 'wbc', re: /\b(?:leucocit\w*|w\.?b\.?c\.?|white\s+blood\s+cell|leu)\b/i }, { campo: 'neutro_abs', re: /\bgran?#/i }, { campo: 'neutro', re: /\b(?:neutr[oó]fil\w*|neut\b|neu\b|gran?(?!#))\b/i }, { campo: 'linfo_abs', re: /\blymp?#/i }, { campo: 'linfo', re: /\b(?:linf[oa]cit\w*|lymph\w*|linf\b|lym(?!#))\b/i }, { campo: 'mono_abs', re: /\bmon\w*#/i }, { campo: 'mono', re: /\b(?:monocit\w*|mono\b|mon(?!#))\b/i }, { campo: 'eosino_abs', re: /\beos\w*#/i }, { campo: 'eosino', re: /\b(?:eosino\w*|eos(?!#))\b/i }, { campo: 'baso_abs', re: /\bbas\w*#/i }, { campo: 'baso', re: /\b(?:bas[oó]fil\w*|bas(?!#))\b/i }, // Hematología: Plaquetas { campo: 'plt', re: /\b(?:plaqueta\w*|platelet\w*|plt\b|trc\b)\b/i }, { campo: 'mpv', re: /\b(?:m\.?p\.?v\.?|vol(?:umen)?\s+plaquetario\s+medio)\b/i }, { campo: 'pct', re: /\b(?:p\.?c\.?t\.?\b|plaquetocrit\w*)\b/i }, // Bioquímica: Enzimas Hepáticas { campo: 'alt', re: /\b(?:alt\b|gpt\b|alanin[ao]?\s+amino\w*)\b/i }, { campo: 'ast', re: /\b(?:ast\b|got\b|aspart\w*)\b/i }, { campo: 'fal', re: /\b(?:fal\b|alp\b|fosfatasa\s+alcalin\w*|alkaline\s+phosph\w*)\b/i }, { campo: 'ggt', re: /\b(?:g\.?g\.?t\.?|gamma\s*glutamil\w*|gama\s*glutamil\w*)\b/i }, // Bioquímica: Función Hepática { campo: 'bili', re: /\b(?:bilirrub\w*\s+total|total\s+bilirubin\w*|tbil\b)\b/i }, { campo: 'bili', re: /\b(?:bilirrub\w*|bilirubin\w*|bili\b)\b/i }, { campo: 'bili_dir', re: /\b(?:bilirrub\w*\s+direct\w*|direct\w*\s+bilirubin\w*|bili\s*dir\b)\b/i }, { campo: 'acidos_bil', re: /\b(?:[aá]cid\w*\s+biliares?|bile\s+acids?|ácidos?\s+bil\w*)\b/i }, // Bioquímica: Función Renal { campo: 'bun', claveConv: 'bun', re: /\b(?:bun\b|nitr[oó]geno\s+ureico)\b/i }, { campo: 'bun', claveConv: 'urea', re: /\burea\b/i }, { campo: 'creat', re: /\b(?:creatinin[ao]?\w*|crea\b)\b/i }, { campo: 'sdma', re: /\b(?:sdma\b|dimetilargin\w*|symmetric\s+dime\w*)\b/i }, // Bioquímica: Metabolitos { campo: 'gluc', re: /\b(?:gluco(?:sa|se)\b|glucemia\b|glu\b)\b/i }, { campo: 'prot', re: /\b(?:prote[íi]nas?\s+totales?|prot\s+total|tp)\b/i }, { campo: 'alb', re: /\b(?:alb[úu]min[ao]?\w*|alb\b)\b/i }, { campo: 'glob', re: /\b(?:globulin\w*|glob\b)\b/i }, { campo: 'fosf', re: /\b(?:f[oó]sforo\b|phosph\w*|phos\b)\b/i }, { campo: 'calc', re: /\b(?:calcio\b|calcium\b|ca\b)\b/i }, { campo: 'fruc', re: /\b(?:fructosamina\b|fructosamine\b|fruc\b)\b/i }, // Bioquímica: Electrolitos { campo: 'sodio', re: /\b(?:sodio\b|sodium\b)\b/i }, { campo: 'potasio', re: /\b(?:potasio\b|potassium\b)\b/i }, { campo: 'cloro', re: /\b(?:clor[ou]\w*|chloride\w*)\b/i }, { campo: 'tco2', re: /\b(?:tco2\b|t\.?co\.?2\b|bicarbonat\w*|co2\s+total)\b/i }, // Bioquímica: Lípidos { campo: 'colest', re: /\b(?:colesterol\b|cholesterol\b|chol\b)\b/i }, { campo: 'trigli', re: /\b(?:triglicérid\w*|triglic[eé]rid\w*|trig\b)\b/i }, // Bioquímica: Enzimas { campo: 'lipasa', re: /\b(?:lipas[ae]\b|lipa\b)\b/i }, { campo: 'ck', re: /\b(?:c\.?k\.?\b|creatina?\s+kinas[ae]|creatine\s+kinas[ae])\b/i }, // Perfil Endocrino { campo: 'cortisol_bas', re: /\b(?:cortisol\s+bas[ae]?l?)\b/i }, { campo: 'cortisol_acth', re: /\b(?:cortisol\s+(?:post[-\s]?acth|post)\b)/i }, { campo: 't4_total', re: /\b(?:t4\s+total|t4\s+libre|tiroxin\w*|thyroxin\w*)\b/i }, { campo: 'insulina', re: /\b(?:insulin[ao]?\w*)\b/i }, // Urianálisis { campo: 'usg', re: /\b(?:usg\b|densidad\s+(?:urin|orin)\w*|gravedad\s+esp\w*)\b/i }, { campo: 'ph', re: /\b(?:ph\s+(?:urin|orin)\w*|ph\s+orina)\b/i }, ]; // Campos select semicuantitativos const DEFS_SEMICUANTITATIVOS: AnalitoDef[] = [ { campo: 'uri-prot', re: /\b(?:prote[íi]nas?\s*(?:en\s*orina?|urin\w*)?|proteinuria)\b/i }, { campo: 'uri-gluc', re: /\b(?:glucosuria\b|glucosa\s+(?:en\s*)?orina\w*)\b/i }, ]; const CONVERSIONES_UNIDADES: Record = { hgb: [ { re: /\bg\/L\b/i, factor: (v) => v / 10 }, { re: /\bmmol\/L\b/i, factor: (v) => v * 1.6113 }, ], hct: [{ re: /\bL\/L\b/i, factor: (v) => (v < 1.5 ? v * 100 : v) }], chcm: [ { re: /\bg\/L\b/i, factor: (v) => v / 10 }, { re: /\bmmol\/L\b/i, factor: (v) => v * 0.6206 }, ], pct: [{ re: /\bL\/L\b/i, factor: (v) => (v < 1.5 ? v * 100 : v) }], wbc: [{ re: /^[\s]*\/[μuµ]?[Ll]\b/, factor: (v) => (v > 100 ? v / 1000 : v) }], plt: [{ re: /^[\s]*\/[μuµ]?[Ll]\b/, factor: (v) => (v > 1000 ? v / 1000 : v) }], bun: [{ re: /\bmmol\/L\b/i, factor: (v) => v * 2.8 }], urea: [ { re: /\bmmol\/L\b/i, factor: (v) => v * 2.8 }, { re: /\bmg\/dL\b/i, factor: (v) => v * 0.467 }, ], creat: [{ re: /\b[μuµ]mol\/L\b/i, factor: (v) => v / 88.4 }], sdma: [ { re: /\bnmol\/L\b/i, factor: (v) => v / 5.899 }, { re: /\b[μuµ]g\/L\b/i, factor: (v) => v / 10 }, ], gluc: [{ re: /\bmmol\/L\b/i, factor: (v) => v * 18.016 }], prot: [{ re: /\bg\/L\b/i, factor: (v) => v / 10 }], alb: [{ re: /\bg\/L\b/i, factor: (v) => v / 10 }], glob: [{ re: /\bg\/L\b/i, factor: (v) => v / 10 }], bili: [{ re: /\b[μuµ]mol\/L\b/i, factor: (v) => v / 17.1 }], bili_dir: [{ re: /\b[μuµ]mol\/L\b/i, factor: (v) => v / 17.1 }], fosf: [{ re: /\bmmol\/L\b/i, factor: (v) => v * 3.097 }], calc: [ { re: /\bmmol\/L\b/i, factor: (v) => v * 4.008 }, { re: /\bm[Ee]q\/L\b/, factor: (v) => v * 2.004 }, ], colest: [{ re: /\bmmol\/L\b/i, factor: (v) => v * 38.67 }], trigli: [{ re: /\bmmol\/L\b/i, factor: (v) => v * 88.57 }], cortisol_bas: [{ re: /\bnmol\/L\b/i, factor: (v) => v / 27.59 }], cortisol_acth: [{ re: /\bnmol\/L\b/i, factor: (v) => v / 27.59 }], t4_total: [ { re: /\b[μuµ]g\/dL\b/i, factor: (v) => v * 12.87 }, { re: /\bng\/dL\b/i, factor: (v) => v * 0.01287 }, { re: /\bng\/mL\b/i, factor: (v) => v * 0.1287 }, ], insulina: [{ re: /\bpmol\/L\b/i, factor: (v) => v / 6.945 }], }; function aplicarConversion(campo: string, claveConv: string | undefined, value: number, cadenaUnidad: string): number { const key = claveConv || campo; const reglas = CONVERSIONES_UNIDADES[key]; if (!reglas) return value; for (const regla of reglas) { if (regla.re.test(cadenaUnidad)) { const f = regla.factor; const convertido = typeof f === 'function' ? f(value) : value * f; return Math.round(convertido * 10000) / 10000; } } return value; } function extraerValorYUnidad(contexto: string): { num: number | null; unit: string } { const m = contexto.match(/[<>≤≥]?\s*(\d+(?:[.,]\d+)?)([\s\S]*)/); if (!m) return { num: null, unit: '' }; const v = parseFloat(m[1].replace(',', '.')); if (!isFinite(v) || v <= 0) return { num: null, unit: '' }; return { num: v, unit: m[2].slice(0, 50) }; } function parsearSemiCuantitativo(text: string): string | null { const t = text.toLowerCase(); if (/negati|nég|neg\b|ausente|absent|no\s+detect/.test(t)) return 'neg'; if (/\+{3}/.test(t)) return '+++'; if (/\+{2}/.test(t)) return '++'; if (/\+/.test(t)) return '+'; if (/traz|trace/.test(t)) return '+'; return null; } function parsearTextoLab(textoCrudo: string): Resultados { const resultados: Resultados = {}; for (const def of DEFS_ANALITOS) { if (resultados[def.campo] !== undefined) continue; const match = def.re.exec(textoCrudo); if (!match) continue; const contexto = textoCrudo.slice(match.index + match[0].length, match.index + match[0].length + 150); const { num, unit } = extraerValorYUnidad(contexto); if (num === null) continue; resultados[def.campo] = aplicarConversion(def.campo, def.claveConv, num, unit); } // Derivar % desde conteos absolutos si el % no se encontro directamente y se conoce el WBC const wbc = resultados.wbc; if (typeof wbc === 'number' && wbc > 0) { for (const f of ['neutro', 'linfo', 'mono', 'eosino', 'baso']) { const abs = resultados[`${f}_abs`]; if (resultados[f] === undefined && typeof abs === 'number') { const pct = Math.round((abs / wbc) * 100); if (pct >= 0 && pct <= 100) resultados[f] = pct; } } } // Derivar % de reticulocitos desde el conteo absoluto y RBC si no se encontro directamente const rbc = resultados.rbc; const retiAbs = resultados.reti_abs; if (typeof rbc === 'number' && rbc > 0 && resultados.reti === undefined && typeof retiAbs === 'number') { const pct = retiAbs / (rbc * 10); if (pct >= 0 && pct <= 20) resultados.reti = Math.round(pct * 100) / 100; } for (const def of DEFS_SEMICUANTITATIVOS) { if (resultados[def.campo] !== undefined) continue; const match = def.re.exec(textoCrudo); if (!match) continue; const contexto = textoCrudo.slice(match.index, match.index + 80); const val = parsearSemiCuantitativo(contexto); if (val) resultados[def.campo] = val; } return resultados; } // Detección de información del paciente const RAZAS_CANINO = [ 'labrador', 'golden retriever', 'golden', 'pastor alemán', 'pastor aleman', 'pastor', 'poodle', 'caniche', 'beagle', 'bulldog', 'dachshund', 'salchicha', 'teckel', 'husky', 'chihuahu', 'maltés', 'maltes', 'yorkshire', 'terrier', 'doberman', 'rottweiler', 'boxer', 'bóxer', 'schnauzer', 'cocker', 'spaniel', 'border collie', 'border', 'dálmata', 'dalmatian', 'pitbull', 'pit bull', 'american staffordshire', 'samoyedo', 'akita', 'shiba', 'galgo', 'greyhound', 'whippet', 'bichón', 'bichon', 'weimaraner', 'setter', 'pointer', 'vizsla', 'basset', 'mastín', 'mastin', 'mastiff', 'bullmastiff', 'dogo', 'cane corso', 'pomerania', 'pomeran', 'pequinés', 'pekinese', 'chow chow', 'shar pei', 'gran danés', 'great dane', 'san bernardo', 'saint bernard', 'bernese', 'spitz', 'pinscher', 'shih tzu', 'lhasa', 'basenji', 'rhodesian', ]; const RAZAS_FELINO = [ 'persa', 'persian', 'siamés', 'siames', 'siamese', 'bengala', 'bengal', 'maine coon', 'ragdoll', 'abisinio', 'abyssinian', 'birmano', 'burmese', 'angora', 'sphynx', 'esfinge', 'scottish fold', 'scottish', 'munchkin', 'tonkinés', 'cornish rex', 'devon rex', 'noruego', 'norwegian', 'british shorthair', 'british', 'russian blue', 'azul ruso', 'ocicat', 'exótico', 'exotic shorthair', 'ragamuffin', 'balinés', 'balinese', ]; const SIGUIENTE_ETIQUETA = /\b(?:edad|age|sexo|sex|g[eé]nero|gender|especie|species|dueño|owner|propietario|doctor|vet|fecha|date|n[uú]m|caso|case|id|muestra|sample|peso|weight)\b/i; interface PacientePdf { especie?: string; raza?: string; sexo?: string; edad?: number; edadUnidad?: string } function inferEspecie(raza: string): string | null { const r = raza.toLowerCase(); if (RAZAS_CANINO.some((b) => r.includes(b))) return 'Canino'; if (RAZAS_FELINO.some((b) => r.includes(b))) return 'Felino'; return null; } function parsearTextoPaciente(textoCrudo: string): PacientePdf { const p: PacientePdf = {}; const coincEsp = textoCrudo.match(/\b(?:especies?|species|tipo(?:\s+de)?\s+animal)\s*:?\s{0,4}([A-Za-záéíóúÁÉÍÓÚñÑ]{3,20})/i); if (coincEsp) { const v = coincEsp[1].toLowerCase(); if (/can[io]|perro|dog/.test(v)) p.especie = 'Canino'; else if (/fel[io]|gat[ao]|cat/.test(v)) p.especie = 'Felino'; } const coincRaza = textoCrudo.match(/\b(?:raza|breed|race|cruce)\s*:?\s{0,4}([^\n\r;:]{2,60})/i); if (coincRaza) { const crudo = coincRaza[1]; const indiceParo = crudo.search(SIGUIENTE_ETIQUETA); const limpiado = (indiceParo > 0 ? crudo.slice(0, indiceParo) : crudo) .split(/\s{2,}/)[0] .trim(); if (limpiado.length >= 2) p.raza = limpiado.length > 40 ? limpiado.slice(0, 40).trim() : limpiado; } if (!p.especie && p.raza) { const inferida = inferEspecie(p.raza); if (inferida) p.especie = inferida; } const coincSex = textoCrudo.match(/\b(?:sexo|sex[ou]?|g[eé]nero|gender)\s*:?\s{0,4}([^\n\r;:]{1,30})/i); if (coincSex) { const v = coincSex[1].trim(); if (/\b(?:macho|male|castrado|neutered)\b/i.test(v) || /^m\.?\s*$/i.test(v)) p.sexo = 'Macho'; else if (/\b(?:hembra|female|esterilizada?|spayed)\b/i.test(v) || /^[fh]\.?\s*$/i.test(v)) p.sexo = 'Hembra'; } const coincEdad = textoCrudo.match(/\b(?:edad|age)\s*:?\s{0,4}(\d+(?:[.,]\d+)?)\s*(a[ñn]os?|years?|yr?s?|meses?|months?)\b/i); if (coincEdad) { p.edad = parseFloat(coincEdad[1].replace(',', '.')); p.edadUnidad = /^m/i.test(coincEdad[2]) ? 'meses' : 'anyos'; } return p; } async function cargarPdfJs(): Promise { const w = window as unknown as { pdfjsLib?: PdfjsLib }; if (w.pdfjsLib) return w.pdfjsLib; await new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = 'assets/lib/pdfjs/pdf.min.js'; script.onload = () => resolve(); script.onerror = () => reject(new Error('no se pudo cargar pdf.js')); document.head.appendChild(script); }); return w.pdfjsLib; } async function extraerTextoPdf(file: File): Promise { const pdfjs = await cargarPdfJs(); if (!pdfjs) throw new Error('PDF.js no cargado'); pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER; const buf = await file.arrayBuffer(); const pdf = await pdfjs.getDocument({ data: buf }).promise; const paginas: string[] = []; for (let p = 1; p <= pdf.numPages; p++) { const page = await pdf.getPage(p); const content = await page.getTextContent(); paginas.push(content.items.map((i) => i.str + (i.hasEOL ? '\n' : ' ')).join('')); } return paginas.join('\n'); } export function inicializarParserPdf(evaluar: () => void): void { document.querySelectorAll('.btn-importar-pdf').forEach((btn) => { btn.addEventListener('click', (e) => { e.stopPropagation(); (document.getElementById(`pdf-input-${btn.dataset.panel}`) as HTMLInputElement | null)?.click(); }); }); document.querySelectorAll('.pdf-input').forEach((input) => { input.addEventListener('change', manejadorAsync('Importar PDF', async () => { const file = input.files?.[0]; if (!file) return; input.value = ''; try { const textoCrudo = await extraerTextoPdf(file); const resultados = parsearTextoLab(textoCrudo); const contadorLab = aplicarValoresAFormulario(resultados, evaluar); const patient = parsearTextoPaciente(textoCrudo); const contadorPac = aplicarPacienteAFormulario(patient); const partes: string[] = []; if (contadorLab > 0) partes.push(`${contadorLab} valor${contadorLab !== 1 ? 'es' : ''}`); if (contadorPac > 0) partes.push('datos del paciente'); mostrarToast(partes.length > 0 ? `${partes.join(' y ')} importados del PDF.` : 'No se encontraron datos reconocibles en el PDF.', partes.length === 0); } catch { mostrarToast('Error al leer el PDF. ¿Es un PDF con texto (no escaneado)?', true); } })); }); }