Jose Salazar Claude Opus 5 commited on
Commit
c0b119e
·
1 Parent(s): e4a79ec

pdf.js 4.x vendorizado y pruebas del extractor de PDF

Browse files

La 3.x leía mal los CMap `ToUnicode` con destinos de un byte —fuera de
especificación, pero los emiten informes de laboratorio reales generados con
Ghostscript— y devolvía cada carácter desplazado 8 bits ('H' → U+4800), con lo
que la importación fallaba entera con «No se encontraron datos reconocibles».
El desplazamiento NO se puede reparar a posteriori: el '0' desplazado cae en
U+3000 y el extractor lo normaliza a espacio, así que una ALT de 260 U/L se
leería como 26. Por eso `textoIlegible()` detecta el caso y aborta en vez de
importar números equivocados.

pdf.js 4+ se distribuye como módulo ES y ya no publica `window.pdfjsLib`, de ahí
el paso de .js a .mjs y la carga dinámica. La versión es un invariante: los
ficheros de `assets/lib/pdfjs/` se copian de la devDependency con
`npm run vendor-pdfjs` para que navegador y pruebas usen la misma.

`frontend/tests/pdf-parser.test.ts` fija todo esto con un PDF construido con esa
anomalía, y cubre la ventana de aceptación de valores: sólo en la línea de su
etiqueta o en la `RESULTADO` que sigue a su cabecera (la ventana ciega anterior
importaba el cociente A/G como albúmina).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

assets/lib/pdfjs/pdf.min.js DELETED
The diff for this file is too large to render. See raw diff
 
assets/lib/pdfjs/pdf.min.mjs ADDED
The diff for this file is too large to render. See raw diff
 
assets/lib/pdfjs/pdf.worker.min.js DELETED
The diff for this file is too large to render. See raw diff
 
assets/lib/pdfjs/pdf.worker.min.mjs ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package-lock.json CHANGED
@@ -13,6 +13,7 @@
13
  "@typescript-eslint/parser": "^8.20.0",
14
  "eslint": "^9.18.0",
15
  "jsdom": "^29.1.1",
 
16
  "typescript": "^5.7.3",
17
  "vite": "^6.0.7",
18
  "vitest": "^3.0.2"
@@ -958,6 +959,283 @@
958
  "dev": true,
959
  "license": "MIT"
960
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
961
  "node_modules/@rollup/rollup-android-arm-eabi": {
962
  "version": "4.62.2",
963
  "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
@@ -2898,6 +3176,19 @@
2898
  "node": ">= 14.16"
2899
  }
2900
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
2901
  "node_modules/picocolors": {
2902
  "version": "1.1.1",
2903
  "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
 
13
  "@typescript-eslint/parser": "^8.20.0",
14
  "eslint": "^9.18.0",
15
  "jsdom": "^29.1.1",
16
+ "pdfjs-dist": "^6.2.108",
17
  "typescript": "^5.7.3",
18
  "vite": "^6.0.7",
19
  "vitest": "^3.0.2"
 
959
  "dev": true,
960
  "license": "MIT"
961
  },
962
+ "node_modules/@napi-rs/canvas": {
963
+ "version": "1.0.3",
964
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.3.tgz",
965
+ "integrity": "sha512-OlI657a5XXvKGFX7kNeIzJ8rO7IXt87Mqu2H8rXE46viAuOfum/JA7ysX7+eBhxNKznT+RCZh418mndlcFX3+w==",
966
+ "dev": true,
967
+ "license": "MIT",
968
+ "optional": true,
969
+ "workspaces": [
970
+ "e2e/*"
971
+ ],
972
+ "engines": {
973
+ "node": ">= 10"
974
+ },
975
+ "funding": {
976
+ "type": "github",
977
+ "url": "https://github.com/sponsors/Brooooooklyn"
978
+ },
979
+ "optionalDependencies": {
980
+ "@napi-rs/canvas-android-arm64": "1.0.3",
981
+ "@napi-rs/canvas-darwin-arm64": "1.0.3",
982
+ "@napi-rs/canvas-darwin-x64": "1.0.3",
983
+ "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.3",
984
+ "@napi-rs/canvas-linux-arm64-gnu": "1.0.3",
985
+ "@napi-rs/canvas-linux-arm64-musl": "1.0.3",
986
+ "@napi-rs/canvas-linux-riscv64-gnu": "1.0.3",
987
+ "@napi-rs/canvas-linux-x64-gnu": "1.0.3",
988
+ "@napi-rs/canvas-linux-x64-musl": "1.0.3",
989
+ "@napi-rs/canvas-win32-arm64-msvc": "1.0.3",
990
+ "@napi-rs/canvas-win32-x64-msvc": "1.0.3"
991
+ }
992
+ },
993
+ "node_modules/@napi-rs/canvas-android-arm64": {
994
+ "version": "1.0.3",
995
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.3.tgz",
996
+ "integrity": "sha512-7kSCdUhoXiO+AaIMXdBGdtp6EctZNkmF62Rea/BmVQlwKaM3bBhOzyGUzxyxz9dv5vdBfpyAaxhSRSJF4kqK4A==",
997
+ "cpu": [
998
+ "arm64"
999
+ ],
1000
+ "dev": true,
1001
+ "license": "MIT",
1002
+ "optional": true,
1003
+ "os": [
1004
+ "android"
1005
+ ],
1006
+ "engines": {
1007
+ "node": ">= 10"
1008
+ },
1009
+ "funding": {
1010
+ "type": "github",
1011
+ "url": "https://github.com/sponsors/Brooooooklyn"
1012
+ }
1013
+ },
1014
+ "node_modules/@napi-rs/canvas-darwin-arm64": {
1015
+ "version": "1.0.3",
1016
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.3.tgz",
1017
+ "integrity": "sha512-ds14V1BPagLszQyaDTeggny5fNeTCqsUQ5QhFj9VDxSEfzrVxXtdbR0LoFyKa0Siaaw8KvqSk4t7k/WoZJwvbg==",
1018
+ "cpu": [
1019
+ "arm64"
1020
+ ],
1021
+ "dev": true,
1022
+ "license": "MIT",
1023
+ "optional": true,
1024
+ "os": [
1025
+ "darwin"
1026
+ ],
1027
+ "engines": {
1028
+ "node": ">= 10"
1029
+ },
1030
+ "funding": {
1031
+ "type": "github",
1032
+ "url": "https://github.com/sponsors/Brooooooklyn"
1033
+ }
1034
+ },
1035
+ "node_modules/@napi-rs/canvas-darwin-x64": {
1036
+ "version": "1.0.3",
1037
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.3.tgz",
1038
+ "integrity": "sha512-qof3LRAAycmkV2I1izZo9RoSHF8kCQr5O05sFwv0jK8rSdYV6KHVwimo6Qb7RxZj40WHKbLHm5JDaUF0o5XUAA==",
1039
+ "cpu": [
1040
+ "x64"
1041
+ ],
1042
+ "dev": true,
1043
+ "license": "MIT",
1044
+ "optional": true,
1045
+ "os": [
1046
+ "darwin"
1047
+ ],
1048
+ "engines": {
1049
+ "node": ">= 10"
1050
+ },
1051
+ "funding": {
1052
+ "type": "github",
1053
+ "url": "https://github.com/sponsors/Brooooooklyn"
1054
+ }
1055
+ },
1056
+ "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
1057
+ "version": "1.0.3",
1058
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.3.tgz",
1059
+ "integrity": "sha512-FU2kKZLmolHA9+KcUA+l1+xH3WTLUUTQDU/kLv9SEUr2TrRPu94aytOeizFJDHPs/QBcw4QL1mCQhetQXYBbag==",
1060
+ "cpu": [
1061
+ "arm"
1062
+ ],
1063
+ "dev": true,
1064
+ "license": "MIT",
1065
+ "optional": true,
1066
+ "os": [
1067
+ "linux"
1068
+ ],
1069
+ "engines": {
1070
+ "node": ">= 10"
1071
+ },
1072
+ "funding": {
1073
+ "type": "github",
1074
+ "url": "https://github.com/sponsors/Brooooooklyn"
1075
+ }
1076
+ },
1077
+ "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
1078
+ "version": "1.0.3",
1079
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.3.tgz",
1080
+ "integrity": "sha512-GVSjntxKeA+/y/ZKf1F+cmUw1WeIkE5aMRPqnZUlBTBvBcrvgWccJAWuYCKPX4QJQwZILIIwhgdAbl51yj6fpA==",
1081
+ "cpu": [
1082
+ "arm64"
1083
+ ],
1084
+ "dev": true,
1085
+ "libc": [
1086
+ "glibc"
1087
+ ],
1088
+ "license": "MIT",
1089
+ "optional": true,
1090
+ "os": [
1091
+ "linux"
1092
+ ],
1093
+ "engines": {
1094
+ "node": ">= 10"
1095
+ },
1096
+ "funding": {
1097
+ "type": "github",
1098
+ "url": "https://github.com/sponsors/Brooooooklyn"
1099
+ }
1100
+ },
1101
+ "node_modules/@napi-rs/canvas-linux-arm64-musl": {
1102
+ "version": "1.0.3",
1103
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.3.tgz",
1104
+ "integrity": "sha512-J51oK/axyZ13kxycumSMfLiDZMdWdOVvqDFI28BpuViZHE3A0bQfr8B5vg8YnPEnqLD3BSn1hkdlh2buspEcNQ==",
1105
+ "cpu": [
1106
+ "arm64"
1107
+ ],
1108
+ "dev": true,
1109
+ "libc": [
1110
+ "musl"
1111
+ ],
1112
+ "license": "MIT",
1113
+ "optional": true,
1114
+ "os": [
1115
+ "linux"
1116
+ ],
1117
+ "engines": {
1118
+ "node": ">= 10"
1119
+ },
1120
+ "funding": {
1121
+ "type": "github",
1122
+ "url": "https://github.com/sponsors/Brooooooklyn"
1123
+ }
1124
+ },
1125
+ "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
1126
+ "version": "1.0.3",
1127
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.3.tgz",
1128
+ "integrity": "sha512-CtQgQjoVTX67jS9XuCTtJ40Sl7wRLMguoFnnGnfDmCWf7kzKFZVwj5ynqUOIGKFMSB61ZCuQlwPvVNxYTTseaw==",
1129
+ "cpu": [
1130
+ "riscv64"
1131
+ ],
1132
+ "dev": true,
1133
+ "libc": [
1134
+ "glibc"
1135
+ ],
1136
+ "license": "MIT",
1137
+ "optional": true,
1138
+ "os": [
1139
+ "linux"
1140
+ ],
1141
+ "engines": {
1142
+ "node": ">= 10"
1143
+ },
1144
+ "funding": {
1145
+ "type": "github",
1146
+ "url": "https://github.com/sponsors/Brooooooklyn"
1147
+ }
1148
+ },
1149
+ "node_modules/@napi-rs/canvas-linux-x64-gnu": {
1150
+ "version": "1.0.3",
1151
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.3.tgz",
1152
+ "integrity": "sha512-jtfzAHFp+FRaR7zGT4jyCe6wUgAG/dVb5A4Apd8FY9jKarntDfUAlJXscugiH7ZF5kKnu7/lHFk9LaDPcrGEVQ==",
1153
+ "cpu": [
1154
+ "x64"
1155
+ ],
1156
+ "dev": true,
1157
+ "libc": [
1158
+ "glibc"
1159
+ ],
1160
+ "license": "MIT",
1161
+ "optional": true,
1162
+ "os": [
1163
+ "linux"
1164
+ ],
1165
+ "engines": {
1166
+ "node": ">= 10"
1167
+ },
1168
+ "funding": {
1169
+ "type": "github",
1170
+ "url": "https://github.com/sponsors/Brooooooklyn"
1171
+ }
1172
+ },
1173
+ "node_modules/@napi-rs/canvas-linux-x64-musl": {
1174
+ "version": "1.0.3",
1175
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.3.tgz",
1176
+ "integrity": "sha512-xTzaUCKUHTY4bCGadeeRZggbRVbGUT1petg7Z8r9AJR2+D9Bqu6nQAgqBGC6D47tA70LjaaaLTrJ7wNY1T74dg==",
1177
+ "cpu": [
1178
+ "x64"
1179
+ ],
1180
+ "dev": true,
1181
+ "libc": [
1182
+ "musl"
1183
+ ],
1184
+ "license": "MIT",
1185
+ "optional": true,
1186
+ "os": [
1187
+ "linux"
1188
+ ],
1189
+ "engines": {
1190
+ "node": ">= 10"
1191
+ },
1192
+ "funding": {
1193
+ "type": "github",
1194
+ "url": "https://github.com/sponsors/Brooooooklyn"
1195
+ }
1196
+ },
1197
+ "node_modules/@napi-rs/canvas-win32-arm64-msvc": {
1198
+ "version": "1.0.3",
1199
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.3.tgz",
1200
+ "integrity": "sha512-ktVLuBkI6QVOm5BwO/WbdGwxgeetAMJa7TTmR8qBarXF0OU2NKjvjUtPJAl2y8t+zBRczJl/1VOl9gua6WcK2g==",
1201
+ "cpu": [
1202
+ "arm64"
1203
+ ],
1204
+ "dev": true,
1205
+ "license": "MIT",
1206
+ "optional": true,
1207
+ "os": [
1208
+ "win32"
1209
+ ],
1210
+ "engines": {
1211
+ "node": ">= 10"
1212
+ },
1213
+ "funding": {
1214
+ "type": "github",
1215
+ "url": "https://github.com/sponsors/Brooooooklyn"
1216
+ }
1217
+ },
1218
+ "node_modules/@napi-rs/canvas-win32-x64-msvc": {
1219
+ "version": "1.0.3",
1220
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.3.tgz",
1221
+ "integrity": "sha512-SGhlQ8bDjL1Cz2KnsKMasr/5sTcwG/SZkB6WCJxLsmSm/3aS2C+3p39bA7iZ2/94+NkVDySZfbiGoaSZSFHYxA==",
1222
+ "cpu": [
1223
+ "x64"
1224
+ ],
1225
+ "dev": true,
1226
+ "license": "MIT",
1227
+ "optional": true,
1228
+ "os": [
1229
+ "win32"
1230
+ ],
1231
+ "engines": {
1232
+ "node": ">= 10"
1233
+ },
1234
+ "funding": {
1235
+ "type": "github",
1236
+ "url": "https://github.com/sponsors/Brooooooklyn"
1237
+ }
1238
+ },
1239
  "node_modules/@rollup/rollup-android-arm-eabi": {
1240
  "version": "4.62.2",
1241
  "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
 
3176
  "node": ">= 14.16"
3177
  }
3178
  },
3179
+ "node_modules/pdfjs-dist": {
3180
+ "version": "6.2.108",
3181
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz",
3182
+ "integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==",
3183
+ "dev": true,
3184
+ "license": "Apache-2.0",
3185
+ "engines": {
3186
+ "node": ">=22.13.0 || >=24"
3187
+ },
3188
+ "optionalDependencies": {
3189
+ "@napi-rs/canvas": "^1.0.0"
3190
+ }
3191
+ },
3192
  "node_modules/picocolors": {
3193
  "version": "1.1.1",
3194
  "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
frontend/package.json CHANGED
@@ -8,6 +8,7 @@
8
  "dev": "vite",
9
  "build": "tsc --noEmit && vite build && npm run copy-assets",
10
  "copy-assets": "mkdir -p ../dist/assets && cp -R ../assets/. ../dist/assets/",
 
11
  "preview": "vite preview",
12
  "test": "vitest run",
13
  "test:watch": "vitest",
@@ -20,6 +21,7 @@
20
  "@typescript-eslint/parser": "^8.20.0",
21
  "eslint": "^9.18.0",
22
  "jsdom": "^29.1.1",
 
23
  "typescript": "^5.7.3",
24
  "vite": "^6.0.7",
25
  "vitest": "^3.0.2"
 
8
  "dev": "vite",
9
  "build": "tsc --noEmit && vite build && npm run copy-assets",
10
  "copy-assets": "mkdir -p ../dist/assets && cp -R ../assets/. ../dist/assets/",
11
+ "vendor-pdfjs": "cp node_modules/pdfjs-dist/build/pdf.min.mjs node_modules/pdfjs-dist/build/pdf.worker.min.mjs ../assets/lib/pdfjs/",
12
  "preview": "vite preview",
13
  "test": "vitest run",
14
  "test:watch": "vitest",
 
21
  "@typescript-eslint/parser": "^8.20.0",
22
  "eslint": "^9.18.0",
23
  "jsdom": "^29.1.1",
24
+ "pdfjs-dist": "^6.2.108",
25
  "typescript": "^5.7.3",
26
  "vite": "^6.0.7",
27
  "vitest": "^3.0.2"
frontend/src/main.ts CHANGED
@@ -6,6 +6,7 @@ import { analizarResultados } from './analisis.js';
6
  import { colapsarPatrones, inicializarSincMob, imagenesDataUrl, capturasMicroscopio } from './ui.js';
7
  import { llamarIA, inicializarConfigBackend } from './ia.js';
8
  import { inicializarParserPdf } from './pdf-parser.js';
 
9
  import { inicializarImportLab } from './lab-import.js';
10
  import { verificarAuth, abrirModalAuth } from './auth.js';
11
  import { abrirModalPapers, inicializarModalPapers } from './papers.js';
@@ -33,6 +34,7 @@ if (btnTema) {
33
  let referencias: Referencias = {};
34
  let alteraciones: Alteraciones = {};
35
  let ultimoAnalisis: ResultadoAnalisis = { hallazgos: [], patrones: [] };
 
36
 
37
  const cargarReferencias = async (): Promise<void> => {
38
  try {
@@ -144,6 +146,9 @@ const evaluar = (): void => {
144
  const valores = obtenerValoresFormulario();
145
  const { hallazgos, patrones } = analizarResultados(valores, paciente, referencias, alteraciones);
146
  ultimoAnalisis = { hallazgos, patrones };
 
 
 
147
 
148
  actualizarClasesInputs(hallazgos);
149
  renderizarPatrones(patrones);
@@ -170,6 +175,7 @@ elId<HTMLSelectElement>('pt-sexo').addEventListener('change', evaluar);
170
 
171
  inicializarSincMob(evaluar);
172
  void inicializarConfigBackend(); // pide la lista de modelos al servidor; no bloquea el arranque
 
173
  inicializarParserPdf(evaluar);
174
  inicializarImportLab(evaluar);
175
  inicializarModalPapers();
@@ -202,7 +208,10 @@ const imagenesActuales = (): string[] =>
202
  const dispararIA = (): void => {
203
  colapsarPatrones(true);
204
  // Se encola desde un callback síncrono (abrirModalAuth), así que no se puede await aquí.
205
- sinEsperar('Análisis IA', llamarIA(obtenerDatosPaciente, () => ultimoAnalisis, imagenesActuales));
 
 
 
206
  };
207
 
208
  const botonAnalizar = document.querySelector<HTMLElement>('.boton-analizar');
 
6
  import { colapsarPatrones, inicializarSincMob, imagenesDataUrl, capturasMicroscopio } from './ui.js';
7
  import { llamarIA, inicializarConfigBackend } from './ia.js';
8
  import { inicializarParserPdf } from './pdf-parser.js';
9
+ import { inicializarPanelesVacios } from './panel-vacio.js';
10
  import { inicializarImportLab } from './lab-import.js';
11
  import { verificarAuth, abrirModalAuth } from './auth.js';
12
  import { abrirModalPapers, inicializarModalPapers } from './papers.js';
 
34
  let referencias: Referencias = {};
35
  let alteraciones: Alteraciones = {};
36
  let ultimoAnalisis: ResultadoAnalisis = { hallazgos: [], patrones: [] };
37
+ let ultimosMedidos: string[] = [];
38
 
39
  const cargarReferencias = async (): Promise<void> => {
40
  try {
 
146
  const valores = obtenerValoresFormulario();
147
  const { hallazgos, patrones } = analizarResultados(valores, paciente, referencias, alteraciones);
148
  ultimoAnalisis = { hallazgos, patrones };
149
+ // Los medidos van aparte y NO dentro de ResultadoAnalisis: eso es la salida del motor, que
150
+ // sólo devuelve lo alterado. Aquí interesa el panel completo, incluidos los valores en rango.
151
+ ultimosMedidos = Object.keys(valores);
152
 
153
  actualizarClasesInputs(hallazgos);
154
  renderizarPatrones(patrones);
 
175
 
176
  inicializarSincMob(evaluar);
177
  void inicializarConfigBackend(); // pide la lista de modelos al servidor; no bloquea el arranque
178
+ inicializarPanelesVacios();
179
  inicializarParserPdf(evaluar);
180
  inicializarImportLab(evaluar);
181
  inicializarModalPapers();
 
208
  const dispararIA = (): void => {
209
  colapsarPatrones(true);
210
  // Se encola desde un callback síncrono (abrirModalAuth), así que no se puede await aquí.
211
+ sinEsperar(
212
+ 'Análisis IA',
213
+ llamarIA(obtenerDatosPaciente, () => ({ ...ultimoAnalisis, medidos: ultimosMedidos }), imagenesActuales),
214
+ );
215
  };
216
 
217
  const botonAnalizar = document.querySelector<HTMLElement>('.boton-analizar');
frontend/src/pdf-parser.ts CHANGED
@@ -4,7 +4,8 @@
4
  import { aplicarValoresAFormulario, aplicarPacienteAFormulario, mostrarToast } from './form-inject.js';
5
  import { manejadorAsync } from './async.js';
6
 
7
- const PDFJS_WORKER = 'assets/lib/pdfjs/pdf.worker.min.js';
 
8
 
9
  interface AnalitoDef { campo: string; re: RegExp; claveConv?: string }
10
  interface ConversionRegla { re: RegExp; factor: number | ((v: number) => number) }
@@ -12,14 +13,16 @@ type Resultados = Record<string, number | string>;
12
 
13
  interface PdfjsLib {
14
  GlobalWorkerOptions: { workerSrc: string };
15
- getDocument(opts: { data: ArrayBuffer }): { promise: Promise<PdfDoc> };
16
  }
17
  interface PdfDoc { numPages: number; getPage(n: number): Promise<PdfPage> }
18
  interface PdfPage { getTextContent(): Promise<{ items: Array<{ str: string; hasEOL?: boolean }> }> }
19
 
20
  const DEFS_ANALITOS: AnalitoDef[] = [
21
  // Hematología: Serie Roja
22
- { campo: 'rbc', re: /\b(?:eritrocit\w*|gl[oó]bulos?\s+rojos?|r\.?b\.?c\.?|eri)\b/i },
 
 
23
  { campo: 'hgb', re: /\b(?:hemoglobin[ao]?\w*|hgb|hb)\b(?!a\d)/i },
24
  { campo: 'hct', re: /\b(?:hematocrit[oo]?\w*|hct|pcv)\b/i },
25
  { campo: 'vcm', re: /\b(?:v\.?c\.?m\.?|m\.?c\.?v\.?|vol(?:umen)?\s+corp\w*)\b/i },
@@ -34,7 +37,10 @@ const DEFS_ANALITOS: AnalitoDef[] = [
34
  // Hematología: Serie Blanca
35
  { campo: 'wbc', re: /\b(?:leucocit\w*|w\.?b\.?c\.?|white\s+blood\s+cell|leu)\b/i },
36
  { campo: 'neutro_abs', re: /\bgran?#/i },
37
- { campo: 'neutro', re: /\b(?:neutr[oó]fil\w*|neut\b|neu\b|gran?(?!#))\b/i },
 
 
 
38
  { campo: 'linfo_abs', re: /\blymp?#/i },
39
  { campo: 'linfo', re: /\b(?:linf[oa]cit\w*|lymph\w*|linf\b|lym(?!#))\b/i },
40
  { campo: 'mono_abs', re: /\bmon\w*#/i },
@@ -88,12 +94,13 @@ const DEFS_ANALITOS: AnalitoDef[] = [
88
 
89
  // Bioquímica: Enzimas
90
  { campo: 'lipasa', re: /\b(?:lipas[ae]\b|lipa\b)\b/i },
91
- { campo: 'ck', re: /\b(?:c\.?k\.?\b|creatina?\s+kinas[ae]|creatine\s+kinas[ae])\b/i },
 
92
 
93
  // Perfil Endocrino
94
  { campo: 'cortisol_bas', re: /\b(?:cortisol\s+bas[ae]?l?)\b/i },
95
  { campo: 'cortisol_acth', re: /\b(?:cortisol\s+(?:post[-\s]?acth|post)\b)/i },
96
- { campo: 't4_total', re: /\b(?:t4\s+total|t4\s+libre|tiroxin\w*|thyroxin\w*)\b/i },
97
  { campo: 'insulina', re: /\b(?:insulin[ao]?\w*)\b/i },
98
 
99
  // Urianálisis
@@ -167,11 +174,20 @@ function aplicarConversion(campo: string, claveConv: string | undefined, value:
167
  return value;
168
  }
169
 
170
- function extraerValorYUnidad(contexto: string): { num: number | null; unit: string } {
 
 
 
 
 
 
 
 
171
  const m = contexto.match(/[<>≤≥]?\s*(\d+(?:[.,]\d+)?)([\s\S]*)/);
172
  if (!m) return { num: null, unit: '' };
173
  const v = parseFloat(m[1].replace(',', '.'));
174
- if (!isFinite(v) || v <= 0) return { num: null, unit: '' };
 
175
  return { num: v, unit: m[2].slice(0, 50) };
176
  }
177
 
@@ -185,17 +201,91 @@ function parsearSemiCuantitativo(text: string): string | null {
185
  return null;
186
  }
187
 
188
- function parsearTextoLab(textoCrudo: string): Resultados {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  const resultados: Resultados = {};
 
190
 
191
  for (const def of DEFS_ANALITOS) {
192
  if (resultados[def.campo] !== undefined) continue;
193
- const match = def.re.exec(textoCrudo);
194
- if (!match) continue;
195
- const contexto = textoCrudo.slice(match.index + match[0].length, match.index + match[0].length + 150);
196
- const { num, unit } = extraerValorYUnidad(contexto);
197
- if (num === null) continue;
198
- resultados[def.campo] = aplicarConversion(def.campo, def.claveConv, num, unit);
199
  }
200
 
201
  // Derivar % desde conteos absolutos si el % no se encontro directamente y se conoce el WBC
@@ -220,9 +310,9 @@ function parsearTextoLab(textoCrudo: string): Resultados {
220
 
221
  for (const def of DEFS_SEMICUANTITATIVOS) {
222
  if (resultados[def.campo] !== undefined) continue;
223
- const match = def.re.exec(textoCrudo);
224
  if (!match) continue;
225
- const contexto = textoCrudo.slice(match.index, match.index + 80);
226
  const val = parsearSemiCuantitativo(contexto);
227
  if (val) resultados[def.campo] = val;
228
  }
@@ -266,7 +356,7 @@ function inferEspecie(raza: string): string | null {
266
  return null;
267
  }
268
 
269
- function parsearTextoPaciente(textoCrudo: string): PacientePdf {
270
  const p: PacientePdf = {};
271
 
272
  const coincEsp = textoCrudo.match(/\b(?:especies?|species|tipo(?:\s+de)?\s+animal)\s*:?\s{0,4}([A-Za-záéíóúÁÉÍÓÚñÑ]{3,20})/i);
@@ -276,6 +366,17 @@ function parsearTextoPaciente(textoCrudo: string): PacientePdf {
276
  else if (/fel[io]|gat[ao]|cat/.test(v)) p.especie = 'Felino';
277
  }
278
 
 
 
 
 
 
 
 
 
 
 
 
279
  const coincRaza = textoCrudo.match(/\b(?:raza|breed|race|cruce)\s*:?\s{0,4}([^\n\r;:]{2,60})/i);
280
  if (coincRaza) {
281
  const crudo = coincRaza[1];
@@ -307,23 +408,58 @@ function parsearTextoPaciente(textoCrudo: string): PacientePdf {
307
  return p;
308
  }
309
 
310
- async function cargarPdfJs(): Promise<PdfjsLib | undefined> {
311
- const w = window as unknown as { pdfjsLib?: PdfjsLib };
312
- if (w.pdfjsLib) return w.pdfjsLib;
313
- await new Promise<void>((resolve, reject) => {
314
- const script = document.createElement('script');
315
- script.src = 'assets/lib/pdfjs/pdf.min.js';
316
- script.onload = () => resolve();
317
- script.onerror = () => reject(new Error('no se pudo cargar pdf.js'));
318
- document.head.appendChild(script);
319
- });
320
- return w.pdfjsLib;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  }
322
 
323
  async function extraerTextoPdf(file: File): Promise<string> {
324
  const pdfjs = await cargarPdfJs();
325
- if (!pdfjs) throw new Error('PDF.js no cargado');
326
- pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER;
327
 
328
  const buf = await file.arrayBuffer();
329
  const pdf = await pdfjs.getDocument({ data: buf }).promise;
@@ -333,7 +469,67 @@ async function extraerTextoPdf(file: File): Promise<string> {
333
  const content = await page.getTextContent();
334
  paginas.push(content.items.map((i) => i.str + (i.hasEOL ? '\n' : ' ')).join(''));
335
  }
336
- return paginas.join('\n');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  }
338
 
339
  export function inicializarParserPdf(evaluar: () => void): void {
@@ -349,21 +545,9 @@ export function inicializarParserPdf(evaluar: () => void): void {
349
  const file = input.files?.[0];
350
  if (!file) return;
351
  input.value = '';
352
- try {
353
- const textoCrudo = await extraerTextoPdf(file);
354
- const resultados = parsearTextoLab(textoCrudo);
355
- const contadorLab = aplicarValoresAFormulario(resultados, evaluar);
356
- const patient = parsearTextoPaciente(textoCrudo);
357
- const contadorPac = aplicarPacienteAFormulario(patient);
358
- const partes: string[] = [];
359
- if (contadorLab > 0) partes.push(`${contadorLab} valor${contadorLab !== 1 ? 'es' : ''}`);
360
- if (contadorPac > 0) partes.push('datos del paciente');
361
- mostrarToast(partes.length > 0
362
- ? `${partes.join(' y ')} importados del PDF.`
363
- : 'No se encontraron datos reconocibles en el PDF.', partes.length === 0);
364
- } catch {
365
- mostrarToast('Error al leer el PDF. ¿Es un PDF con texto (no escaneado)?', true);
366
- }
367
  }));
368
  });
 
 
369
  }
 
4
  import { aplicarValoresAFormulario, aplicarPacienteAFormulario, mostrarToast } from './form-inject.js';
5
  import { manejadorAsync } from './async.js';
6
 
7
+ const PDFJS_MODULO = 'assets/lib/pdfjs/pdf.min.mjs';
8
+ const PDFJS_WORKER = 'assets/lib/pdfjs/pdf.worker.min.mjs';
9
 
10
  interface AnalitoDef { campo: string; re: RegExp; claveConv?: string }
11
  interface ConversionRegla { re: RegExp; factor: number | ((v: number) => number) }
 
13
 
14
  interface PdfjsLib {
15
  GlobalWorkerOptions: { workerSrc: string };
16
+ getDocument(opts: { data: ArrayBuffer | Uint8Array }): { promise: Promise<PdfDoc> };
17
  }
18
  interface PdfDoc { numPages: number; getPage(n: number): Promise<PdfPage> }
19
  interface PdfPage { getTextContent(): Promise<{ items: Array<{ str: string; hasEOL?: boolean }> }> }
20
 
21
  const DEFS_ANALITOS: AnalitoDef[] = [
22
  // Hematología: Serie Roja
23
+ // `hematies` es como lo etiquetan los laboratorios españoles (el texto llega ya sin acentos,
24
+ // ver `sinAcentos`).
25
+ { campo: 'rbc', re: /\b(?:eritrocit\w*|hematies|gl[oó]bulos?\s+rojos?|r\.?b\.?c\.?|eri)\b/i },
26
  { campo: 'hgb', re: /\b(?:hemoglobin[ao]?\w*|hgb|hb)\b(?!a\d)/i },
27
  { campo: 'hct', re: /\b(?:hematocrit[oo]?\w*|hct|pcv)\b/i },
28
  { campo: 'vcm', re: /\b(?:v\.?c\.?m\.?|m\.?c\.?v\.?|vol(?:umen)?\s+corp\w*)\b/i },
 
37
  // Hematología: Serie Blanca
38
  { campo: 'wbc', re: /\b(?:leucocit\w*|w\.?b\.?c\.?|white\s+blood\s+cell|leu)\b/i },
39
  { campo: 'neutro_abs', re: /\bgran?#/i },
40
+ // «SEGMENTADOS» es como los informes españoles nombran a los neutrófilos maduros; sin él la
41
+ // fórmula leucocitaria de esos informes se importaba sin neutrófilos, que es el dato que más
42
+ // pesa en la detección de inflamación.
43
+ { campo: 'neutro', re: /\b(?:neutr[oó]fil\w*|segmentad\w*|neut\b|neu\b|gran?(?!#))\b/i },
44
  { campo: 'linfo_abs', re: /\blymp?#/i },
45
  { campo: 'linfo', re: /\b(?:linf[oa]cit\w*|lymph\w*|linf\b|lym(?!#))\b/i },
46
  { campo: 'mono_abs', re: /\bmon\w*#/i },
 
94
 
95
  // Bioquímica: Enzimas
96
  { campo: 'lipasa', re: /\b(?:lipas[ae]\b|lipa\b)\b/i },
97
+ // «CREATINKINASA», en una palabra, es como la etiquetan los informes españoles.
98
+ { campo: 'ck', re: /\b(?:c\.?k\.?\b|creatin[ae]?\s*kinas[ae]|creatine\s+kinas[ae])\b/i },
99
 
100
  // Perfil Endocrino
101
  { campo: 'cortisol_bas', re: /\b(?:cortisol\s+bas[ae]?l?)\b/i },
102
  { campo: 'cortisol_acth', re: /\b(?:cortisol\s+(?:post[-\s]?acth|post)\b)/i },
103
+ { campo: 't4_total', re: /\b(?:t4(?:\s+(?:total|libre))?|tiroxin\w*|thyroxin\w*)\b/i },
104
  { campo: 'insulina', re: /\b(?:insulin[ao]?\w*)\b/i },
105
 
106
  // Urianálisis
 
174
  return value;
175
  }
176
 
177
+ // Campos donde 0 es un resultado clínico legítimo y no ruido de parseo. En el resto se sigue
178
+ // descartando: un 0 suelto suele venir de una fecha o un número de página, no de una medición.
179
+ // «BASÓFILOS 0 %» es la lectura normal de un hemograma sano y se estaba tirando.
180
+ const PERMITEN_CERO = new Set([
181
+ 'neutro', 'neutro_abs', 'linfo', 'linfo_abs', 'mono', 'mono_abs',
182
+ 'eosino', 'eosino_abs', 'baso', 'baso_abs', 'reti', 'reti_abs', 'nrbc',
183
+ ]);
184
+
185
+ function extraerValorYUnidad(contexto: string, campo = ''): { num: number | null; unit: string } {
186
  const m = contexto.match(/[<>≤≥]?\s*(\d+(?:[.,]\d+)?)([\s\S]*)/);
187
  if (!m) return { num: null, unit: '' };
188
  const v = parseFloat(m[1].replace(',', '.'));
189
+ const minimo = PERMITEN_CERO.has(campo) ? 0 : Number.MIN_VALUE;
190
+ if (!isFinite(v) || v < minimo) return { num: null, unit: '' };
191
  return { num: v, unit: m[2].slice(0, 50) };
192
  }
193
 
 
201
  return null;
202
  }
203
 
204
+ // Los informes españoles escriben los analitos en mayúsculas Y acentuados (EOSINÓFILOS,
205
+ // BASÓFILOS, HEMATÍES). `\w` no cubre 'Ó', así que `eosino\w*` no casa con «EOSINÓFILOS» y toda
206
+ // la fórmula leucocitaria se perdía. Se quitan los diacríticos ANTES de buscar; las posiciones
207
+ // se conservan porque NFD + borrado de marcas no cambia el número de caracteres base.
208
+ function sinAcentos(texto: string): string {
209
+ return texto.normalize('NFD').replace(/\p{M}/gu, '');
210
+ }
211
+
212
+ // Dónde puede estar el valor de una etiqueta. Antes era una ventana ciega de 150 caracteres, que
213
+ // se saltaba líneas y secciones enteras: medido con un informe real, «densidad urinaria» —citada
214
+ // de pasada en el párrafo interpretativo del SDMA— capturaba el resultado de la CREATINKINASA
215
+ // dos líneas más abajo e importaba una densidad urinaria de 90 en una analítica sin orina. Un
216
+ // valor equivocado es peor que un valor ausente: el que falta se ve, el que sobra se interpreta.
217
+ //
218
+ // Las dos disposiciones reales son:
219
+ // 1. En la misma línea: `HEMATOCRITO....... 48,1 %`
220
+ // 2. Bajo una cabecera: `CREATININA / SUERO` / `Química seca - …` / `RESULTADO....... 1,73`
221
+ // Así que se lee el resto de la línea y, si no hay número, se avanza hasta una línea RESULTADO,
222
+ // parando en cuanto aparece otra cabecera en mayúsculas (es decir, otro analito).
223
+ const MAX_LINEAS_BUSCADAS = 6;
224
+ const RE_LINEA_RESULTADO = /^\s*(?:resultado|result)\b/i;
225
+
226
+ function esCabecera(linea: string): boolean {
227
+ const letras = linea.replace(/[^A-Za-zÁÉÍÓÚÑáéíóúñ]/g, '');
228
+ if (letras.length < 3) return false;
229
+ return letras === letras.toUpperCase();
230
+ }
231
+
232
+ // Se prueban TODAS las apariciones de la etiqueta, no sólo la primera, y gana la primera que
233
+ // venga acompañada de un número. Los informes nombran el analito antes de medirlo —«FÓRMULA
234
+ // LEUCOCITARIA» es el título de la sección y «LEUCOCITOS....... 7.510» el dato—, así que quedarse
235
+ // con la primera aparición perdía el valor. El orden importa: si la primera sí trae número, es la
236
+ // que se usa, igual que antes.
237
+ const MAX_APARICIONES = 5;
238
+ const globales = new Map<RegExp, RegExp>();
239
+
240
+ function comoGlobal(re: RegExp): RegExp {
241
+ let g = globales.get(re);
242
+ if (!g) {
243
+ g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
244
+ globales.set(re, g);
245
+ }
246
+ g.lastIndex = 0;
247
+ return g;
248
+ }
249
+
250
+ function primerValorDe(texto: string, def: AnalitoDef): number | null {
251
+ const re = comoGlobal(def.re);
252
+ for (let i = 0; i < MAX_APARICIONES; i++) {
253
+ const match = re.exec(texto);
254
+ if (!match) return null;
255
+ const contexto = contextoDeValor(texto, match.index + match[0].length);
256
+ const { num, unit } = extraerValorYUnidad(contexto, def.campo);
257
+ if (num !== null) return aplicarConversion(def.campo, def.claveConv, num, unit);
258
+ }
259
+ return null;
260
+ }
261
+
262
+ function contextoDeValor(texto: string, desde: number): string {
263
+ const lineas = texto.slice(desde, desde + 600).split('\n');
264
+ const resto = lineas[0] ?? '';
265
+ if (/\d/.test(resto)) return resto;
266
+
267
+ for (const linea of lineas.slice(1, MAX_LINEAS_BUSCADAS)) {
268
+ if (RE_LINEA_RESULTADO.test(linea)) return linea;
269
+ // Otra cabecera en mayúsculas o una línea que YA trae un número: en ambos casos lo que
270
+ // viene después pertenece a otro analito. Sin la segunda condición, «COCIENTE ALBÚMINA /
271
+ // GLOBULINA» se saltaba la línea `ALBUMINA... 40 g/L` y se quedaba con el RESULTADO de
272
+ // debajo, que es el cociente A/G (1,11) y no la albúmina.
273
+ if (esCabecera(linea) || /\d/.test(linea)) break;
274
+ }
275
+ return '';
276
+ }
277
+
278
+ // Exportadas para las pruebas: son puras (texto → datos) y concentran toda la lógica de
279
+ // reconocimiento, que es donde han estado los fallos.
280
+ export function parsearTextoLab(textoCrudo: string): Resultados {
281
  const resultados: Resultados = {};
282
+ const texto = sinAcentos(textoCrudo);
283
 
284
  for (const def of DEFS_ANALITOS) {
285
  if (resultados[def.campo] !== undefined) continue;
286
+ const encontrado = primerValorDe(texto, def);
287
+ if (encontrado === null) continue;
288
+ resultados[def.campo] = encontrado;
 
 
 
289
  }
290
 
291
  // Derivar % desde conteos absolutos si el % no se encontro directamente y se conoce el WBC
 
310
 
311
  for (const def of DEFS_SEMICUANTITATIVOS) {
312
  if (resultados[def.campo] !== undefined) continue;
313
+ const match = def.re.exec(texto);
314
  if (!match) continue;
315
+ const contexto = texto.slice(match.index, match.index + 80);
316
  const val = parsearSemiCuantitativo(contexto);
317
  if (val) resultados[def.campo] = val;
318
  }
 
356
  return null;
357
  }
358
 
359
+ export function parsearTextoPaciente(textoCrudo: string): PacientePdf {
360
  const p: PacientePdf = {};
361
 
362
  const coincEsp = textoCrudo.match(/\b(?:especies?|species|tipo(?:\s+de)?\s+animal)\s*:?\s{0,4}([A-Za-záéíóúÁÉÍÓÚñÑ]{3,20})/i);
 
366
  else if (/fel[io]|gat[ao]|cat/.test(v)) p.especie = 'Felino';
367
  }
368
 
369
+ // Muchos informes no rotulan «Especie:»: escriben el nombre científico o el común junto al
370
+ // nombre del animal («EDNA / Perra, Canis lupus familiaris»). Se busca sólo si la etiqueta
371
+ // explícita no dio nada, para que ésta siga mandando.
372
+ if (!p.especie) {
373
+ if (/\bcanis\s+(?:lupus\s+)?familiaris\b|\bperr[ao]\b/i.test(textoCrudo)) p.especie = 'Canino';
374
+ else if (/\bfelis\s+(?:silvestris\s+)?catus\b|\bgat[ao]\b/i.test(textoCrudo)) p.especie = 'Felino';
375
+ }
376
+
377
+ // Y el sexo se deduce del mismo sitio: «Perra»/«Gata» sólo existen en femenino.
378
+ if (/\b(?:perra|gata)\b/i.test(textoCrudo)) p.sexo = 'Hembra';
379
+
380
  const coincRaza = textoCrudo.match(/\b(?:raza|breed|race|cruce)\s*:?\s{0,4}([^\n\r;:]{2,60})/i);
381
  if (coincRaza) {
382
  const crudo = coincRaza[1];
 
408
  return p;
409
  }
410
 
411
+ class ErrorTextoIlegible extends Error {
412
+ constructor() {
413
+ super('el texto del PDF no se pudo decodificar de forma fiable');
414
+ this.name = 'ErrorTextoIlegible';
415
+ }
416
+ }
417
+
418
+ let pdfjsCargado: PdfjsLib | undefined;
419
+
420
+ // pdf.js 4+ se distribuye como módulo ES y ya no publica `window.pdfjsLib`, así que se carga
421
+ // con import() dinámico en vez de inyectar un <script>. Sigue siendo perezoso a propósito:
422
+ // son ~1,7 MB entre módulo y worker, y sólo hacen falta si el usuario adjunta un PDF.
423
+ //
424
+ // La versión importa. La 3.11 leía mal los CMap `ToUnicode` con destinos de UN byte —fuera de
425
+ // especificación, pero los emiten productores reales (informes de laboratorio generados con
426
+ // Ghostscript)— y devolvía cada carácter desplazado 8 bits: 'H' (0x48) salía como U+4800. El
427
+ // texto extraído no casaba con ningún analito y la importación fallaba entera. Ver
428
+ // `frontend/tests/pdf-parser.test.ts`, que fija el caso con un PDF de ese tipo.
429
+ async function cargarPdfJs(): Promise<PdfjsLib> {
430
+ if (pdfjsCargado) return pdfjsCargado;
431
+ // @vite-ignore: es un asset vendorizado que se resuelve en tiempo de ejecución; la build no
432
+ // debe intentar empaquetarlo.
433
+ const modulo = (await import(
434
+ /* @vite-ignore */ new URL(PDFJS_MODULO, document.baseURI).href
435
+ )) as unknown as PdfjsLib;
436
+ modulo.GlobalWorkerOptions.workerSrc = new URL(PDFJS_WORKER, document.baseURI).href;
437
+ pdfjsCargado = modulo;
438
+ return modulo;
439
+ }
440
+
441
+ /** Texto que salió del extractor con los bytes descolocados (ver `cargarPdfJs`).
442
+ *
443
+ * No se intenta reparar aunque la transformación sea reversible sobre el papel: el extractor
444
+ * normaliza a espacio los códigos que caen en un espacio Unicode, y '0' (0x30) desplazado es
445
+ * U+3000 (espacio ideográfico). Los ceros se pierden ANTES de que este código vea el texto, así
446
+ * que «reparar» convertiría una ALT de 260 U/L en 26. Vale más no importar nada que importar un
447
+ * número equivocado en una analítica.
448
+ */
449
+ function textoIlegible(texto: string): boolean {
450
+ let noAscii = 0;
451
+ let desplazados = 0;
452
+ for (const ch of texto) {
453
+ const c = ch.codePointAt(0) as number;
454
+ if (c <= 0x7f) continue;
455
+ noAscii++;
456
+ if ((c & 0xff) === 0 && c >>> 8 >= 0x20) desplazados++;
457
+ }
458
+ return noAscii >= 20 && desplazados / noAscii > 0.5;
459
  }
460
 
461
  async function extraerTextoPdf(file: File): Promise<string> {
462
  const pdfjs = await cargarPdfJs();
 
 
463
 
464
  const buf = await file.arrayBuffer();
465
  const pdf = await pdfjs.getDocument({ data: buf }).promise;
 
469
  const content = await page.getTextContent();
470
  paginas.push(content.items.map((i) => i.str + (i.hasEOL ? '\n' : ' ')).join(''));
471
  }
472
+ const texto = paginas.join('\n');
473
+ if (textoIlegible(texto)) throw new ErrorTextoIlegible();
474
+ return texto;
475
+ }
476
+
477
+ // Lee un PDF y vuelca lo que reconozca en el formulario. El PDF se parsea entero, sin importar
478
+ // desde qué panel se haya adjuntado: los valores caen en el panel al que pertenece cada analito.
479
+ async function importarPdf(file: File, evaluar: () => void): Promise<void> {
480
+ try {
481
+ const textoCrudo = await extraerTextoPdf(file);
482
+ const resultados = parsearTextoLab(textoCrudo);
483
+ const contadorLab = aplicarValoresAFormulario(resultados, evaluar);
484
+ const patient = parsearTextoPaciente(textoCrudo);
485
+ const contadorPac = aplicarPacienteAFormulario(patient);
486
+ const partes: string[] = [];
487
+ if (contadorLab > 0) partes.push(`${contadorLab} valor${contadorLab !== 1 ? 'es' : ''}`);
488
+ if (contadorPac > 0) partes.push('datos del paciente');
489
+ mostrarToast(partes.length > 0
490
+ ? `${partes.join(' y ')} importados del PDF.`
491
+ : 'No se encontraron datos reconocibles en el PDF.', partes.length === 0);
492
+ } catch {
493
+ mostrarToast('Error al leer el PDF. ¿Es un PDF con texto (no escaneado)?', true);
494
+ }
495
+ }
496
+
497
+ // Arrastrar y soltar sobre un panel de exámenes. Se escucha en el <section> entero y no sólo en
498
+ // la zona vacía: una vez el panel muestra el formulario la zona desaparece, y soltar otro PDF
499
+ // encima del panel debe seguir funcionando.
500
+ function inicializarArrastre(evaluar: () => void): void {
501
+ document.querySelectorAll<HTMLElement>('.subpanel[id^="panel-"]').forEach((panel) => {
502
+ if (!panel.querySelector('.panel-vacio')) return;
503
+
504
+ // `dragover` con preventDefault es lo que le dice al navegador que aquí se puede soltar;
505
+ // sin él, el drop lo captura la ventana y navega al fichero.
506
+ panel.addEventListener('dragover', (e) => {
507
+ e.preventDefault();
508
+ panel.classList.add('arrastrando');
509
+ });
510
+ // `dragleave` salta también al pasar sobre un hijo; sólo cuenta salir del panel de verdad.
511
+ panel.addEventListener('dragleave', (e) => {
512
+ if (!panel.contains((e as DragEvent).relatedTarget as Node | null)) {
513
+ panel.classList.remove('arrastrando');
514
+ }
515
+ });
516
+ panel.addEventListener('drop', manejadorAsync('Importar PDF', async (e: Event) => {
517
+ e.preventDefault();
518
+ panel.classList.remove('arrastrando');
519
+ const file = (e as DragEvent).dataTransfer?.files?.[0];
520
+ if (!file) return;
521
+ if (file.type !== 'application/pdf' && !/\.pdf$/i.test(file.name)) {
522
+ mostrarToast('Sólo se pueden adjuntar archivos PDF.', true);
523
+ return;
524
+ }
525
+ await importarPdf(file, evaluar);
526
+ }));
527
+ });
528
+
529
+ // Soltar FUERA de un panel no debe abrir el PDF sustituyendo la aplicación (con el formulario
530
+ // a medio rellenar, eso es perder el trabajo por un gesto impreciso).
531
+ document.addEventListener('dragover', (e) => e.preventDefault());
532
+ document.addEventListener('drop', (e) => e.preventDefault());
533
  }
534
 
535
  export function inicializarParserPdf(evaluar: () => void): void {
 
545
  const file = input.files?.[0];
546
  if (!file) return;
547
  input.value = '';
548
+ await importarPdf(file, evaluar);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
549
  }));
550
  });
551
+
552
+ inicializarArrastre(evaluar);
553
  }
frontend/tests/pdf-parser.test.ts ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @vitest-environment jsdom
2
+ // Regresión del importador de PDF. Cubre las dos capas donde ha fallado:
3
+ //
4
+ // 1. La DECODIFICACIÓN del texto (pdf.js). Un informe real de laboratorio no se pudo importar
5
+ // —«No se encontraron datos reconocibles en el PDF»— porque su CMap `ToUnicode` declara
6
+ // destinos de UN byte (fuera de especificación, pero lo emiten productores reales tipo
7
+ // Ghostscript) y pdf.js 3.11 los leía de dos en dos: cada carácter salía desplazado 8 bits,
8
+ // 'H' (0x48) como U+4800. Aquí se construye un PDF con esa misma anomalía y se fija que la
9
+ // versión de pdf.js que vendorizamos lo decodifica bien.
10
+ //
11
+ // 2. El RECONOCIMIENTO sobre el texto ya decodificado (parsearTextoLab / parsearTextoPaciente).
12
+ //
13
+ // El texto de las pruebas de la capa 2 reproduce la MAQUETA del informe real (dot leaders,
14
+ // cabecera de sección + línea RESULTADO, párrafos interpretativos) con datos inventados: el
15
+ // informe original lleva nombre de propietario, clínica, dirección y nº de chip, y no tiene por
16
+ // qué entrar en el repositorio para fijar una regresión de parseo.
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs';
20
+ import { parsearTextoLab, parsearTextoPaciente } from '../src/pdf-parser.js';
21
+
22
+ // --- Capa 1: decodificación -----------------------------------------------------------------
23
+
24
+ /** PDF mínimo cuyo `ToUnicode` usa destinos de un byte (`<48><48><48>` en vez de `<48><48><0048>`). */
25
+ function pdfConToUnicodeDefectuoso(lineas: string[]): Uint8Array {
26
+ const usados = [...new Set(lineas.join('').split(''))].filter((c) => c !== ' ');
27
+ const bfchar = usados
28
+ .map((c) => {
29
+ const hex = c.charCodeAt(0).toString(16).padStart(2, '0');
30
+ return `<${hex}><${hex}>`; // ← destino de UN byte: la anomalía que se quiere reproducir
31
+ })
32
+ .join('\n');
33
+ const cmap = `/CIDInit /ProcSet findresource begin
34
+ 12 dict begin
35
+ begincmap
36
+ /CMapType 2 def
37
+ 1 begincodespacerange
38
+ <00><ff>
39
+ endcodespacerange
40
+ ${usados.length} beginbfchar
41
+ ${bfchar}
42
+ endbfchar
43
+ endcmap
44
+ CMapName currentdict /CMap defineresource pop
45
+ end end`;
46
+
47
+ const escapar = (s: string) => s.replace(/[\\()]/g, (c) => `\\${c}`);
48
+ const contenido = `BT /F1 10 Tf 40 750 Td 14 TL\n${lineas
49
+ .map((l) => `(${escapar(l)}) Tj T*`)
50
+ .join('\n')}\nET`;
51
+
52
+ const objetos = [
53
+ '<< /Type /Catalog /Pages 2 0 R >>',
54
+ '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
55
+ '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] '
56
+ + '/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
57
+ '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /ToUnicode 6 0 R >>',
58
+ `<< /Length ${contenido.length} >>\nstream\n${contenido}\nendstream`,
59
+ `<< /Length ${cmap.length} >>\nstream\n${cmap}\nendstream`,
60
+ ];
61
+
62
+ let pdf = '%PDF-1.4\n';
63
+ const offsets: number[] = [];
64
+ objetos.forEach((cuerpo, i) => {
65
+ offsets.push(pdf.length);
66
+ pdf += `${i + 1} 0 obj\n${cuerpo}\nendobj\n`;
67
+ });
68
+ const inicioXref = pdf.length;
69
+ pdf += `xref\n0 ${objetos.length + 1}\n0000000000 65535 f \n`;
70
+ for (const off of offsets) pdf += `${String(off).padStart(10, '0')} 00000 n \n`;
71
+ pdf += `trailer\n<< /Size ${objetos.length + 1} /Root 1 0 R >>\nstartxref\n${inicioXref}\n%%EOF\n`;
72
+
73
+ return Uint8Array.from(pdf, (c) => c.charCodeAt(0));
74
+ }
75
+
76
+ async function extraer(datos: Uint8Array): Promise<string> {
77
+ const doc = await pdfjs.getDocument({ data: datos }).promise;
78
+ const paginas: string[] = [];
79
+ for (let p = 1; p <= doc.numPages; p++) {
80
+ const contenido = await (await doc.getPage(p)).getTextContent();
81
+ // Mismo ensamblado que `extraerTextoPdf` en src/pdf-parser.ts.
82
+ paginas.push(
83
+ contenido.items
84
+ .map((i) => ('str' in i ? i.str + (i.hasEOL ? '\n' : ' ') : ''))
85
+ .join(''),
86
+ );
87
+ }
88
+ return paginas.join('\n');
89
+ }
90
+
91
+ describe('decodificación de PDF con ToUnicode defectuoso', () => {
92
+ it('lee los caracteres sin desplazarlos 8 bits', async () => {
93
+ const texto = await extraer(
94
+ pdfConToUnicodeDefectuoso(['HEMATOCRITO...... 48,1 %', 'ALT-GPT......... 260 U/L']),
95
+ );
96
+
97
+ expect(texto).toContain('HEMATOCRITO');
98
+ expect(texto).toContain('ALT-GPT');
99
+ // El síntoma exacto de la regresión: 'H' saliendo como U+4800 y ',' como U+2C00.
100
+ expect(texto).not.toContain('䠀');
101
+ expect(texto).not.toContain('Ⰰ');
102
+ });
103
+
104
+ it('conserva los ceros (el 0 desplazado caía en U+3000 y se normalizaba a espacio)', async () => {
105
+ const texto = await extraer(pdfConToUnicodeDefectuoso(['ALT-GPT......... 260 U/L']));
106
+
107
+ // Este es el motivo por el que el desplazamiento NO se repara a posteriori: perder los ceros
108
+ // convierte una ALT de 260 U/L en 26, que es un resultado normal.
109
+ expect(texto).toContain('260');
110
+ expect(parsearTextoLab(texto).alt).toBe(260);
111
+ });
112
+ });
113
+
114
+ // --- Capa 2: reconocimiento -----------------------------------------------------------------
115
+
116
+ // Maqueta del informe: etiqueta + líderes de puntos con el valor en la misma línea (hemograma) o
117
+ // cabecera de sección con el valor en una línea RESULTADO posterior (bioquímica).
118
+ const INFORME = `Datos personales
119
+ LUNA
120
+ Perra, Canis lupus familiaris
121
+ PRUEBA RESULTADO UNIDADES VAL.DE REFERENCIA
122
+ HEMOGRAMA
123
+ Contaje y Fórmula Electrónico
124
+ HEMATOCRITO...................... 48,1 % (37,0-55,0)
125
+ HEMOGLOBINA...................... 17,7 g/dL (12,0-20,0)
126
+ HEMATÍES......................... 7.300.000 /µL (5.100.000-8.000.000)
127
+ PLAQUETAS........................ 171.000 /µL (200.000-500.000)
128
+ FÓRMULA LEUCOCITARIA
129
+ LEUCOCITOS....................... 7.510 /µL (4.900-21.700)
130
+ EOSINÓFILOS...................... 5 %
131
+ BASÓFILOS........................ 0 %
132
+ LINFOCITOS....................... 29 %
133
+ SEGMENTADOS...................... 59 %
134
+ UREA / SUERO
135
+ Química seca - Espectrofotometría Ultravioleta-Visible
136
+ RESULTADO........................ 67 mg/dL (11-53)
137
+ CREATININA / SUERO
138
+ Química seca - Espectrofotometría Ultravioleta-Visible
139
+ RESULTADO........................ 1,73 mg/dL (0,50-1,60)
140
+ PROTEÍNAS TOTALES / SUERO
141
+ Química seca - Espectrofotometría Ultravioleta-Visible
142
+ RESULTADO........................ 76 g/L (47-68)
143
+ COCIENTE ALBÚMINA / GLOBULINA
144
+ Química seca - Espectrofotometría Ultravioleta-Visible
145
+ ALBUMINA......................... 40 g/L (24-40)
146
+ GLOBULINAS TOTALES............... 36,00 g/L (21,00-35,00)
147
+ RESULTADO........................ 1,11 (0,70-1,50)
148
+ ALT-GPT
149
+ Química seca - Espectrofotometría Ultravioleta-Visible
150
+ RESULTADO........................ 260 U/L (21-79)
151
+ SDMA (Dimetilarginina simétrica)
152
+ Inmunoturbidimetría
153
+ RESULTADO........................ 12,6 µg/dL (<15,0)
154
+ Interpretación:
155
+ El aumento de SDMA en suero o plasma es un indicador temprano de reducción de la
156
+ funcionalidad renal. IRIS recomienda valorar los resultados de SDMA junto a otros
157
+ parámetros; entre ellos UPC, Creatinina, presión arterial y densidad urinaria.
158
+ CREATINKINASA
159
+ Química seca - Espectrofotometría Ultravioleta-Visible
160
+ RESULTADO........................ 90 U/L (<300)
161
+ `;
162
+
163
+ describe('parsearTextoLab sobre la maqueta de informe español', () => {
164
+ const r = parsearTextoLab(INFORME);
165
+
166
+ it('lee los valores de la misma línea', () => {
167
+ expect(r.hct).toBe(48.1);
168
+ expect(r.hgb).toBe(17.7);
169
+ expect(r.wbc).toBe(7.51);
170
+ expect(r.plt).toBe(171);
171
+ });
172
+
173
+ it('lee los valores que van en la línea RESULTADO bajo su cabecera', () => {
174
+ expect(r.creat).toBe(1.73);
175
+ expect(r.alt).toBe(260);
176
+ expect(r.sdma).toBe(12.6);
177
+ expect(r.prot).toBe(7.6); // 76 g/L → g/dL
178
+ expect(r.bun).toBeCloseTo(31.29, 1); // urea 67 mg/dL → BUN
179
+ });
180
+
181
+ it('reconoce los nombres acentuados en mayúsculas', () => {
182
+ expect(r.rbc).toBe(7.3); // HEMATÍES
183
+ expect(r.eosino).toBe(5); // EOSINÓFILOS
184
+ expect(r.linfo).toBe(29);
185
+ });
186
+
187
+ it('reconoce SEGMENTADOS como neutrófilos', () => {
188
+ expect(r.neutro).toBe(59);
189
+ });
190
+
191
+ it('conserva un 0 legítimo de la fórmula leucocitaria', () => {
192
+ expect(r.baso).toBe(0);
193
+ });
194
+
195
+ it('no confunde el analito con el cociente calculado a partir de él', () => {
196
+ // Bajo «COCIENTE ALBÚMINA / GLOBULINA» la línea RESULTADO es el cociente A/G (1,11); la
197
+ // albúmina y las globulinas están en sus propias líneas, encima.
198
+ expect(r.alb).toBe(4); // 40 g/L → g/dL
199
+ expect(r.glob).toBe(3.6); // 36 g/L → g/dL
200
+ });
201
+
202
+ it('no inventa un analito citado de pasada en un párrafo interpretativo', () => {
203
+ // «densidad urinaria» aparece en la interpretación del SDMA. El valor más cercano es el de
204
+ // la CREATINKINASA, dos líneas más abajo: importarlo daría una densidad urinaria de 90 en
205
+ // una analítica que no incluye orina.
206
+ expect(r.usg).toBeUndefined();
207
+ });
208
+ });
209
+
210
+ describe('parsearTextoPaciente', () => {
211
+ it('deduce especie y sexo del nombre científico y del género gramatical', () => {
212
+ expect(parsearTextoPaciente(INFORME)).toMatchObject({ especie: 'Canino', sexo: 'Hembra' });
213
+ });
214
+
215
+ it('la etiqueta explícita manda sobre la deducción', () => {
216
+ const p = parsearTextoPaciente('Especie: Felino\nSexo: Macho\nEl perro del vecino');
217
+ expect(p).toMatchObject({ especie: 'Felino', sexo: 'Macho' });
218
+ });
219
+ });