Spaces:
Sleeping
pdf.js 4.x vendorizado y pruebas del extractor de PDF
Browse filesLa 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 +0 -0
- assets/lib/pdfjs/pdf.min.mjs +0 -0
- assets/lib/pdfjs/pdf.worker.min.js +0 -0
- assets/lib/pdfjs/pdf.worker.min.mjs +0 -0
- frontend/package-lock.json +291 -0
- frontend/package.json +2 -0
- frontend/src/main.ts +10 -1
- frontend/src/pdf-parser.ts +231 -47
- frontend/tests/pdf-parser.test.ts +219 -0
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -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",
|
|
@@ -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"
|
|
@@ -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(
|
|
|
|
|
|
|
|
|
|
| 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');
|
|
@@ -4,7 +4,8 @@
|
|
| 4 |
import { aplicarValoresAFormulario, aplicarPacienteAFormulario, mostrarToast } from './form-inject.js';
|
| 5 |
import { manejadorAsync } from './async.js';
|
| 6 |
|
| 7 |
-
const
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
const resultados: Resultados = {};
|
|
|
|
| 190 |
|
| 191 |
for (const def of DEFS_ANALITOS) {
|
| 192 |
if (resultados[def.campo] !== undefined) continue;
|
| 193 |
-
const
|
| 194 |
-
if (
|
| 195 |
-
|
| 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(
|
| 224 |
if (!match) continue;
|
| 225 |
-
const contexto =
|
| 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 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
}
|
|
@@ -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 |
+
});
|