diff --git a/package.json b/package.json index d5e2a96..044f53b 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,8 @@ "serve": "^14.2.4", "simple-parallax-js": "^6.3.3", "styled-components": "^6.2.0", - "swiper": "^12.1.2" + "swiper": "^12.1.2", + "tesseract.js": "^7.0.0" }, "devDependencies": { "@eslint/js": "^9.30.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb0cfbd..573a8b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: swiper: specifier: ^12.1.2 version: 12.1.2 + tesseract.js: + specifier: ^7.0.0 + version: 7.0.0 devDependencies: '@eslint/js': specifier: ^9.30.1 @@ -645,79 +648,66 @@ packages: resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.55.1': resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.55.1': resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.55.1': resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.55.1': resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.55.1': resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.55.1': resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.55.1': resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.55.1': resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.55.1': resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.55.1': resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.55.1': resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.55.1': resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.55.1': resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} @@ -772,28 +762,24 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [glibc] '@swc/core-linux-arm64-musl@1.15.8': resolution: {integrity: sha512-koiCqL09EwOP1S2RShCI7NbsQuG6r2brTqUYE7pV7kZm9O17wZ0LSz22m6gVibpwEnw8jI3IE1yYsQTVpluALw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [musl] '@swc/core-linux-x64-gnu@1.15.8': resolution: {integrity: sha512-4p6lOMU3bC+Vd5ARtKJ/FxpIC5G8v3XLoPEZ5s7mLR8h7411HWC/LmTXDHcrSXRC55zvAVia1eldy6zDLz8iFQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [glibc] '@swc/core-linux-x64-musl@1.15.8': resolution: {integrity: sha512-z3XBnbrZAL+6xDGAhJoN4lOueIxC/8rGrJ9tg+fEaeqLEuAtHSW2QHDHxDwkxZMjuF/pZ6MUTjHjbp8wLbuRLA==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [musl] '@swc/core-win32-arm64-msvc@1.15.8': resolution: {integrity: sha512-djQPJ9Rh9vP8GTS/Df3hcc6XP6xnG5c8qsngWId/BLA9oX6C7UzCPAn74BG/wGb9a6j4w3RINuoaieJB3t+7iQ==} @@ -869,28 +855,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -1139,6 +1121,9 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bmp-js@0.1.0: + resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==} + boxen@7.0.0: resolution: {integrity: sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==} engines: {node: '>=14.16'} @@ -1636,6 +1621,9 @@ packages: typescript: optional: true + idb-keyval@6.2.5: + resolution: {integrity: sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1766,6 +1754,9 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} + is-url@1.2.4: + resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -1884,28 +1875,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -2010,6 +1997,15 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + notistack@3.0.2: resolution: {integrity: sha512-0R+/arLYbK5Hh7mEfR2adt0tyXJcCC9KkA2hc56FeWik2QN6Bm/S4uW+BjzDARsJth5u06nTjelSw/VSnB1YEA==} engines: {node: '>=12.0.0', npm: '>=6.0.0'} @@ -2057,6 +2053,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + opencollective-postinstall@2.0.3: + resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==} + hasBin: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2251,6 +2251,9 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -2465,6 +2468,12 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tesseract.js-core@7.0.0: + resolution: {integrity: sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==} + + tesseract.js@7.0.0: + resolution: {integrity: sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==} + throttle-debounce@2.3.0: resolution: {integrity: sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==} engines: {node: '>=8'} @@ -2473,6 +2482,9 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -2593,6 +2605,15 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + wasm-feature-detect@1.8.0: + resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2634,6 +2655,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zlibjs@0.3.1: + resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==} + snapshots: '@babel/code-frame@7.27.1': @@ -3506,6 +3530,8 @@ snapshots: balanced-match@1.0.2: {} + bmp-js@0.1.0: {} + boxen@7.0.0: dependencies: ansi-align: 3.0.1 @@ -4134,6 +4160,8 @@ snapshots: optionalDependencies: typescript: 5.8.3 + idb-keyval@6.2.5: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -4260,6 +4288,8 @@ snapshots: dependencies: which-typed-array: 1.1.19 + is-url@1.2.4: {} + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -4449,6 +4479,10 @@ snapshots: negotiator@0.6.4: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + notistack@3.0.2(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: clsx: 1.2.1 @@ -4504,6 +4538,8 @@ snapshots: dependencies: mimic-fn: 2.1.0 + opencollective-postinstall@2.0.3: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -4685,6 +4721,8 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + regenerator-runtime@0.13.11: {} + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -4985,6 +5023,22 @@ snapshots: tapable@2.3.0: {} + tesseract.js-core@7.0.0: {} + + tesseract.js@7.0.0: + dependencies: + bmp-js: 0.1.0 + idb-keyval: 6.2.5 + is-url: 1.2.4 + node-fetch: 2.7.0 + opencollective-postinstall: 2.0.3 + regenerator-runtime: 0.13.11 + tesseract.js-core: 7.0.0 + wasm-feature-detect: 1.8.0 + zlibjs: 0.3.1 + transitivePeerDependencies: + - encoding + throttle-debounce@2.3.0: {} tinyglobby@0.2.15: @@ -4992,6 +5046,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tr46@0.0.3: {} + ts-api-utils@2.4.0(typescript@5.8.3): dependencies: typescript: 5.8.3 @@ -5098,6 +5154,15 @@ snapshots: void-elements@3.1.0: {} + wasm-feature-detect@1.8.0: {} + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -5158,3 +5223,5 @@ snapshots: yaml@1.10.2: {} yocto-queue@0.1.0: {} + + zlibjs@0.3.1: {} diff --git a/src/components/Uploader0.3/Services/ocrService.ts b/src/components/Uploader0.3/Services/ocrService.ts new file mode 100644 index 0000000..2ecd616 --- /dev/null +++ b/src/components/Uploader0.3/Services/ocrService.ts @@ -0,0 +1,128 @@ +import Tesseract from "tesseract.js"; + +export interface OcrCoordinatesResult { + success: boolean; + latitude?: number; + longitude?: number; + raw?: string; +} + +/** + * Crops the bottom portion of the image — GPS Map Camera watermarks always + * appear at the bottom, so we only OCR that slice, which is much faster. + */ +const cropBottomOfImage = (file: File, cropFraction = 0.22): Promise => { + return new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(file); + + img.onload = () => { + const cropHeight = Math.floor(img.height * cropFraction); + const canvas = document.createElement("canvas"); + canvas.width = img.width; + canvas.height = cropHeight; + + const ctx = canvas.getContext("2d"); + if (!ctx) { + URL.revokeObjectURL(url); + reject(new Error("Could not get canvas context")); + return; + } + + // Draw only the bottom slice + ctx.drawImage( + img, + 0, img.height - cropHeight, // source: bottom of image + img.width, cropHeight, + 0, 0, // dest: top of canvas + img.width, cropHeight + ); + + URL.revokeObjectURL(url); + canvas.toBlob( + (blob) => { + if (blob) resolve(blob); + else reject(new Error("canvas.toBlob returned null")); + }, + "image/png" // lossless — better OCR accuracy on text + ); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error("Failed to load image for cropping")); + }; + + img.src = url; + }); +}; + +/** + * Parses coordinates from OCR text. + * + * Handles the formats produced by "GPS Map Camera" and similar apps: + * "Lat 9.393758° Long 78.098491°" + * "Lat: 9.393758 Long: 78.098491" + * "9.393758°N 78.098491°E" + */ +const parseCoordinates = ( + text: string +): { latitude: number; longitude: number } | null => { + // Pattern 1 — GPS Map Camera style: "Lat 9.393758° Long 78.098491°" + const p1 = /[Ll]at[\s:°]*([+-]?\d+\.?\d*)[\s°].*?[Ll]ong?[\s:°]*([+-]?\d+\.?\d*)/s; + const m1 = text.match(p1); + if (m1) { + const lat = parseFloat(m1[1]); + const lon = parseFloat(m1[2]); + if (!isNaN(lat) && !isNaN(lon)) return { latitude: lat, longitude: lon }; + } + + // Pattern 2 — decimal with hemisphere letter: "9.393758°N 78.098491°E" + const p2 = /([+-]?\d+\.\d+)\s*°?\s*([NS])[^\d+-]*([+-]?\d+\.\d+)\s*°?\s*([EW])/i; + const m2 = text.match(p2); + if (m2) { + let lat = parseFloat(m2[1]); + let lon = parseFloat(m2[3]); + if (m2[2].toUpperCase() === "S") lat = -lat; + if (m2[4].toUpperCase() === "W") lon = -lon; + if (!isNaN(lat) && !isNaN(lon)) return { latitude: lat, longitude: lon }; + } + + return null; +}; + +/** + * Runs fully client-side OCR on the image to extract GPS coordinates embedded + * as a text watermark (e.g. by "GPS Map Camera"). + * + * No backend call is made — everything runs in the browser via Tesseract.js. + */ +export const extractCoordinates = async ( + file: File +): Promise => { + try { + const croppedBlob = await cropBottomOfImage(file, 0.22); + + const { + data: { text }, + } = await Tesseract.recognize(croppedBlob, "eng", { + logger: () => {}, // suppress noisy progress events + }); + + const coords = parseCoordinates(text); + + if (coords) { + return { + success: true, + latitude: coords.latitude, + longitude: coords.longitude, + raw: text, + }; + } + + return { success: false, raw: text }; + } catch (err) { + console.error("[ocrService] Tesseract failed:", err); + return { success: false }; + } +}; \ No newline at end of file diff --git a/src/components/Uploader0.3/hooks/useFileUpload.ts b/src/components/Uploader0.3/hooks/useFileUpload.ts index 446a56c..9a7fb71 100644 --- a/src/components/Uploader0.3/hooks/useFileUpload.ts +++ b/src/components/Uploader0.3/hooks/useFileUpload.ts @@ -1,3 +1,4 @@ +import { extractCoordinates } from "../Services/ocrService"; import extractEXIFData from "../utils/Camera/extractEXIFData"; import verifyGPSInImage from '../utils/GPS/verifyGPSInImage'; @@ -38,11 +39,57 @@ export const useFileUpload = ( // Only process GPS data if it's a valid stone inscription const gpsResult = verifyGPSInImage(imageDataUrl); - onFileUploadData(gpsResult.allExif, gpsResult.hasGPS); - if (!gpsResult.hasGPS) { - // errorMessages.push(`Warning: No GPS data found in image ${file.name}`); - errorMessages.push(`Warning: No GPS data found in one or more uploaded files`); + if (gpsResult.hasGPS) { + onFileUploadData( + gpsResult.allExif, + true + ); + } else { + + try { + const ocrResult = + await extractCoordinates(file); + + if ( + ocrResult?.success && + ocrResult.latitude != null && + ocrResult.longitude != null + ) { + + onFileUploadData( + { + OCR: { + latitude: ocrResult.latitude, + longitude: ocrResult.longitude, + }, + }, + true + ); + + } else { + + onFileUploadData( + gpsResult.allExif, + false + ); + + errorMessages.push( + "Warning: No GPS data found" + ); + } + + } catch { + + onFileUploadData( + gpsResult.allExif, + false + ); + + errorMessages.push( + "Warning: No GPS data found" + ); + } } newPhotos.push(imageDataUrl); diff --git a/src/components/Uploader0.3/uploader0.3/InscriptionUploader0.5.tsx b/src/components/Uploader0.3/uploader0.3/InscriptionUploader0.5.tsx index d03eab2..e0e8dab 100644 --- a/src/components/Uploader0.3/uploader0.3/InscriptionUploader0.5.tsx +++ b/src/components/Uploader0.3/uploader0.3/InscriptionUploader0.5.tsx @@ -1,12 +1,13 @@ import React, { useEffect, useRef, useState } from "react"; + import { Alert, CircularProgress, - FormControlLabel, - FormLabel, + // FormControlLabel, + // FormLabel, MenuItem, - Radio, - RadioGroup, + // Radio, + // RadioGroup, Slide, Snackbar, TextField, @@ -27,10 +28,13 @@ import { CameraView } from "../components/CameraView"; import SuggestionControls from "../components/SuggestionControls"; import { useCamera } from "../hooks/UseCamera"; import type { GeoInfo } from "../types/types"; -import { getEnvConfig } from "../config/env"; +// import { getEnvConfig } from "../config/env"; import getCurrentLocation from "../utils/Camera/getCurrentLocation"; import verifyGPSInImage from "../utils/GPS/verifyGPSInImage"; +import embedGPSIntoImage from "../utils/GPS/embedGPSIntoImage"; +import { extractCoordinates } from "../Services/ocrService"; import { suggestionApiClient } from "@/utils/http/clients/suggestionApi.client"; +import piexifjs from "piexifjs"; const isOnline = true; // true => validate with AI, false => skip AI validation only const MAX_IMAGES = 20; @@ -115,6 +119,29 @@ const dataUrlToFile = async (dataUrl: string, fileName: string) => { }); }; +const embedGpsCoordinatesInFile = async ( + imageDataUrl: string, + file: File, + latitude: number, + longitude: number +): Promise => { + if (!imageDataUrl.startsWith("data:image/jpeg;base64,")) { + console.warn("Cannot embed GPS EXIF into non-JPEG image:", file.name); + return file; + } + + try { + const updatedDataUrl = embedGPSIntoImage(imageDataUrl, latitude, longitude, new Date()); + if (updatedDataUrl === imageDataUrl) { + return file; + } + return await dataUrlToFile(updatedDataUrl, file.name); + } catch (error) { + console.error("Failed to embed GPS coordinates into file:", error); + return file; + } +}; + const rotateImageDataUrl = (dataUrl: string, degrees = 90): Promise => new Promise((resolve, reject) => { const image = new Image(); @@ -293,6 +320,22 @@ const buildModerationSnackbarMessage = ( return `topic: ${fieldStatus.topic} | title: ${fieldStatus.title} | description: ${fieldStatus.description}`; }; +type GpsCoordinateSource = "exif" | "ocr"; + +const buildGpsStatusMessage = ( + fileName: string, + source: GpsCoordinateSource, + coordinates: { latitude: string; longitude: string } +) => { + const coordinateText = `lat=${coordinates.latitude}; long=${coordinates.longitude}`; + + if (source === "ocr") { + return `${fileName}: GPS location successfully obtained (${coordinateText})`; + } + + return `${fileName}: GPS location successfully obtained (${coordinateText})`; +}; + const EnhancedInscriptionUploaderV5: React.FC = () => { const [currentStage, setCurrentStage] = useState<"upload" | "grouping">("upload"); const [ungroupedImages, setUngroupedImages] = useState([]); @@ -301,7 +344,7 @@ const EnhancedInscriptionUploaderV5: React.FC = () => { const [isUploading, setIsUploading] = useState(false); const [error, setError] = useState(null); - const [hasGeoData, setHasGeoData] = useState(null); + const [, setHasGeoData] = useState(null); const [geoInfo, setGeoInfo] = useState(null); const [isCheckingStone, setIsCheckingStone] = useState(false); const [stoneCheckResult, setStoneCheckResult] = useState(null); @@ -425,54 +468,93 @@ const EnhancedInscriptionUploaderV5: React.FC = () => { }; const fetchGroupSuggestion = async (groupId: string, lat?: string, lon?: string) => { - setGroupSuggestions((previous) => ({ ...previous, [groupId]: null })); - setGroupSuggestionVisibility((previous) => ({ ...previous, [groupId]: true })); - setGroupSuggestionLoading((previous) => ({ ...previous, [groupId]: true })); + setGroupSuggestions((previous) => ({ ...previous, [groupId]: null })); + setGroupSuggestionVisibility((previous) => ({ ...previous, [groupId]: true })); + setGroupSuggestionLoading((previous) => ({ ...previous, [groupId]: true })); - try { - const latitude = lat || geoInfo?.latitude; - const longitude = lon || geoInfo?.longitude; - let normalizedLatitude = normalizeCoordinate(latitude); - let normalizedLongitude = normalizeCoordinate(longitude); - - if (!normalizedLatitude || !normalizedLongitude) { - const location = await getCurrentLocation(); - normalizedLatitude = normalizeCoordinate(location.latitude); - normalizedLongitude = normalizeCoordinate(location.longitude); - } + try { + const latitude = lat || geoInfo?.latitude; + const longitude = lon || geoInfo?.longitude; + let normalizedLatitude = normalizeCoordinate(latitude); + let normalizedLongitude = normalizeCoordinate(longitude); + + if (!normalizedLatitude || !normalizedLongitude) { + const location = await getCurrentLocation(); + normalizedLatitude = normalizeCoordinate(location.latitude); + normalizedLongitude = normalizeCoordinate(location.longitude); + } + + if (!normalizedLatitude || !normalizedLongitude) { + throw new Error("No coordinates available"); + } + + // Flat POST body — no wrapping, numbers not strings + const response = await suggestionApiClient.post("", { + lat: Number(normalizedLatitude), + lon: Number(normalizedLongitude), + }); + + const outer = response.data; + let text = ""; + + if (outer?.text) { + // text is already a plain string — no JSON.parse needed + text = outer.text; + } else { + text = outer?.description || outer?.suggestion || ""; + } + + if (!text) throw new Error("No suggestion returned"); - if (!normalizedLatitude || !normalizedLongitude) { - throw new Error("No coordinates available"); + setGroupSuggestions((previous) => ({ ...previous, [groupId]: text })); + } catch { + setGroupSuggestions((previous) => ({ + ...previous, + [groupId]: "Failed to get suggestion.", + })); + } finally { + setGroupSuggestionLoading((previous) => ({ ...previous, [groupId]: false })); } + }; - // Flat POST body — no wrapping, numbers not strings - const response = await suggestionApiClient.post("", { - lat: Number(normalizedLatitude), - lon: Number(normalizedLongitude), - }); + const updateGeoInfo = (exifData: any, hasGPS: boolean) => { + if (hasGPS) { + + if (exifData?.GPS) { + console.log( + "[EXIF] GPS found in image metadata" + ); + const coordinates = { + latitude: exifData.GPS[piexifjs.GPSIFD.GPSLatitude], + longitude: exifData.GPS[piexifjs.GPSIFD.GPSLongitude], + timestamp: exifData.GPS[piexifjs.GPSIFD.GPSTimeStamp], + }; + + setHasGeoData(true); + setGeoInfo({ ...coordinates, hasGPS: true }); + return; + } - const outer = response.data; - let text = ""; + if (exifData?.OCR) { + console.log( + "[OCR] GeoInfo updated", + exifData.OCR.latitude, + exifData.OCR.longitude + ); + setGeoInfo({ + latitude: exifData.OCR.latitude, + longitude: exifData.OCR.longitude, + hasGPS: true, + }); - if (outer?.text) { - // text is already a plain string — no JSON.parse needed - text = outer.text; - } else { - text = outer?.description || outer?.suggestion || ""; + setHasGeoData(true); + return; + } } - if (!text) throw new Error("No suggestion returned"); + setHasGeoData(false); + }; - setGroupSuggestions((previous) => ({ ...previous, [groupId]: text })); - } catch { - setGroupSuggestions((previous) => ({ - ...previous, - [groupId]: "Failed to get suggestion.", - })); - } finally { - setGroupSuggestionLoading((previous) => ({ ...previous, [groupId]: false })); - } -}; const checkStone = async (imageDataUrl: string): Promise => { if (!isOnline) { @@ -579,6 +661,7 @@ const EnhancedInscriptionUploaderV5: React.FC = () => { const pendingImages: ImageItem[] = []; const messages: string[] = []; + const gpsStatusMessages: string[] = []; let gpsCoordinateFromBatch: { latitude: string; longitude: string } | null = null; for (const file of Array.from(files)) { @@ -587,8 +670,10 @@ const EnhancedInscriptionUploaderV5: React.FC = () => { break; } + let processedFile: File = file; + try { - const preview = await readFileAsDataUrl(file); + const preview = await readFileAsDataUrl(processedFile); const isStone = await checkStone(preview); if (!isStone) { messages.push(`${file.name}: not a stone inscription`); @@ -596,18 +681,118 @@ const EnhancedInscriptionUploaderV5: React.FC = () => { } const gpsResult = verifyGPSInImage(preview); - const gpsLatitude = normalizeCoordinate(gpsResult.coordinates?.lat); - const gpsLongitude = normalizeCoordinate(gpsResult.coordinates?.lon); - - if (gpsLatitude && gpsLongitude) { - gpsCoordinateFromBatch = { latitude: gpsLatitude, longitude: gpsLongitude }; + let gpsCoordinateSource: GpsCoordinateSource | null = null; + let gpsCoordinatesForMessage: { latitude: string; longitude: string } | null = null; + + if (gpsResult.hasGPS) { + updateGeoInfo( + gpsResult.allExif, + true + ); + + const gpsLatitude = normalizeCoordinate(gpsResult.coordinates?.lat); + const gpsLongitude = normalizeCoordinate(gpsResult.coordinates?.lon); + + if (gpsLatitude && gpsLongitude) { + gpsCoordinateFromBatch = { + latitude: gpsLatitude, + longitude: gpsLongitude, + }; + gpsCoordinatesForMessage = gpsCoordinateFromBatch; + gpsCoordinateSource = "exif"; + } } else { + try { + console.log( + "[OCR] No EXIF GPS found. Falling back to OCR.", + file.name + ); + const ocrResult = + await extractCoordinates(file); + console.log( + "[OCR] Response:", + ocrResult + ); + if ( + ocrResult?.success && + ocrResult.latitude != null && + ocrResult.longitude != null + ) { + gpsCoordinateFromBatch = { + latitude: String(ocrResult.latitude), + longitude: String(ocrResult.longitude), + }; + gpsCoordinatesForMessage = gpsCoordinateFromBatch; + + try { + processedFile = await embedGpsCoordinatesInFile( + preview, + processedFile, + ocrResult.latitude, + ocrResult.longitude + ); + } catch (error) { + console.error("Failed to write OCR GPS into EXIF:", error); + } + + console.log( + "[OCR] Coordinates extracted:", + gpsCoordinateFromBatch + ); + updateGeoInfo( + { + OCR: { + latitude: ocrResult.latitude, + longitude: ocrResult.longitude, + }, + }, + true + ); + + gpsCoordinateSource = "ocr"; + } else { + updateGeoInfo( + gpsResult.allExif, + false + ); + + messages.push( + `${file.name}: no GPS data found` + ); + } + } catch (error) { + + console.error( + "[OCR] Failed:", + error + ); + + updateGeoInfo( + gpsResult.allExif, + false + ); + + messages.push( + `${file.name}: no GPS data found` + ); + } + } + + if (gpsCoordinateSource) { + gpsStatusMessages.push( + buildGpsStatusMessage( + file.name, + gpsCoordinateSource, + gpsCoordinatesForMessage || gpsCoordinateFromBatch! + ) + ); + } else if (gpsResult.hasGPS) { messages.push(`${file.name}: no GPS data found`); } pendingImages.push({ id: createImageId(), - file, + file: processedFile, preview, }); } catch { @@ -641,6 +826,8 @@ const EnhancedInscriptionUploaderV5: React.FC = () => { if (messages.length > 0) { setError(messages.join(" | ")); + } else if (gpsStatusMessages.length > 0) { + showSnackbar("success", gpsStatusMessages.join(" | ")); } event.target.value = ""; @@ -1428,7 +1615,12 @@ const EnhancedInscriptionUploaderV5: React.FC = () => { TransitionComponent={SlideDownTransition} anchorOrigin={{ vertical: "top", horizontal: "center" }} > - + {snackbarMessage.split(" | ").map((line, index) => (
{line}
))} diff --git a/src/components/Uploader0.3/uploader0.3/InscriptionUploader0.6.tsx b/src/components/Uploader0.3/uploader0.3/InscriptionUploader0.6.tsx index 41c6023..729c8ed 100644 --- a/src/components/Uploader0.3/uploader0.3/InscriptionUploader0.6.tsx +++ b/src/components/Uploader0.3/uploader0.3/InscriptionUploader0.6.tsx @@ -24,7 +24,7 @@ import { GPSStatus } from "../components/GPSStatus"; import { useCamera } from "../hooks/UseCamera"; import type { GeoInfo } from "../types/types"; import verifyGPSInImage from "../utils/GPS/verifyGPSInImage"; - +import { extractCoordinates } from "../Services/ocrService"; const isOnline = true; // true => validate with AI, false => skip AI validation only interface ImageItem { @@ -123,16 +123,30 @@ const EnhancedInscriptionUploaderV5: React.FC = () => { }; const updateGeoInfo = (exifData: any, hasGPS: boolean) => { - if (hasGPS && exifData?.GPS) { - const coordinates = { - latitude: exifData.GPS[piexifjs.GPSIFD.GPSLatitude], - longitude: exifData.GPS[piexifjs.GPSIFD.GPSLongitude], - timestamp: exifData.GPS[piexifjs.GPSIFD.GPSTimeStamp], - }; + if (hasGPS) { - setHasGeoData(true); - setGeoInfo({ ...coordinates, hasGPS: true }); - return; + if (exifData?.GPS) { + const coordinates = { + latitude: exifData.GPS[piexifjs.GPSIFD.GPSLatitude], + longitude: exifData.GPS[piexifjs.GPSIFD.GPSLongitude], + timestamp: exifData.GPS[piexifjs.GPSIFD.GPSTimeStamp], + }; + + setHasGeoData(true); + setGeoInfo({ ...coordinates, hasGPS: true }); + return; + } + + if (exifData?.OCR) { + setGeoInfo({ + latitude: exifData.OCR.latitude, + longitude: exifData.OCR.longitude, + hasGPS: true, + }); + + setHasGeoData(true); + return; + } } setHasGeoData(false); @@ -227,10 +241,60 @@ const EnhancedInscriptionUploaderV5: React.FC = () => { } const gpsResult = verifyGPSInImage(preview); - updateGeoInfo(gpsResult.allExif, gpsResult.hasGPS); - if (!gpsResult.hasGPS) { - messages.push(`${file.name}: no GPS data found`); + if (gpsResult.hasGPS) { + + updateGeoInfo( + gpsResult.allExif, + true + ); + console.log("Using EXIF coordinates"); + } else { + + try { + + const ocrResult = + await extractCoordinates(file); + + if ( + ocrResult?.success && + ocrResult.latitude != null && + ocrResult.longitude != null + ) { + + updateGeoInfo( + { + OCR: { + latitude: ocrResult.latitude, + longitude: ocrResult.longitude, + }, + }, + true + ); + console.log("Using OCR fallback coordinates"); + } else { + + updateGeoInfo( + gpsResult.allExif, + false + ); + + messages.push( + `${file.name}: no GPS data found` + ); + } + + } catch { + + updateGeoInfo( + gpsResult.allExif, + false + ); + + messages.push( + `${file.name}: no GPS data found` + ); + } } pendingImages.push({ diff --git a/src/components/Uploader0.3/uploader0.3/piexifjs.d.ts b/src/components/Uploader0.3/uploader0.3/piexifjs.d.ts new file mode 100644 index 0000000..115a8c6 --- /dev/null +++ b/src/components/Uploader0.3/uploader0.3/piexifjs.d.ts @@ -0,0 +1 @@ +declare module 'piexifjs'; diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 51955eb..6ac7b3d 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -5,6 +5,8 @@ import { authClient } from "@/utils/http/clients/authClient.client"; import { apiClient } from "@/utils/http/clients/backendApiClientGeneral"; import { setPostLoginRedirect } from "@/utils/postLoginRedirect"; +const DEV_BYPASS_AUTH = false; + type AuthContextType = { isAuthenticated: boolean; isLoading: boolean; @@ -16,7 +18,8 @@ const AuthContext = createContext(null!); const AUTH_SYNC_STORAGE_KEY = "auth:sync-event"; export const AuthProvider = (props: { children: React.ReactNode }) => { - const [isAuthenticated, setIsAuthenticated] = useState(false); + // const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isAuthenticated, setIsAuthenticated] = useState(DEV_BYPASS_AUTH); const [isLoading, setIsLoading] = useState(true); const hasLoginSucceededRef = useRef(false); const hasForcedLogoutRef = useRef(false); @@ -120,6 +123,9 @@ export const AuthProvider = (props: { children: React.ReactNode }) => { }; useEffect(() => { + if (DEV_BYPASS_AUTH) { + return; + } const bootstrap = async () => { setIsLoading(true); try { diff --git a/src/utils/http/interceptors/authRequest.interceptor.ts b/src/utils/http/interceptors/authRequest.interceptor.ts index 0165a47..5315bed 100644 --- a/src/utils/http/interceptors/authRequest.interceptor.ts +++ b/src/utils/http/interceptors/authRequest.interceptor.ts @@ -7,6 +7,10 @@ const dispatchUnauthorized = () => { if (hasDispatchedUnauthorized) return; hasDispatchedUnauthorized = true; try { + // console.error( + // "[AUTH] Unauthorized dispatched from authRequest.interceptor" + // ); + window.dispatchEvent(new CustomEvent("app:unauthorized")); } catch { window.location.href = "/login"; diff --git a/src/utils/http/interceptors/error.interceptor.ts b/src/utils/http/interceptors/error.interceptor.ts index b8bb916..8c3afd4 100644 --- a/src/utils/http/interceptors/error.interceptor.ts +++ b/src/utils/http/interceptors/error.interceptor.ts @@ -7,6 +7,9 @@ export const errorInterceptor = (client: AxiosInstance) => { if (error.response?.status === 401) { // Dispatch a global event so React context can handle logout and UI updates try { + // console.error( + // "[AUTH] Unauthorized dispatched from error.interceptor" + // ); window.dispatchEvent(new CustomEvent('app:unauthorized')); } catch (e) { // fallback to hard navigation if CustomEvent is not supported diff --git a/src/utils/http/interceptors/refreshToken.interceptor.ts b/src/utils/http/interceptors/refreshToken.interceptor.ts index 4216914..b1b5959 100644 --- a/src/utils/http/interceptors/refreshToken.interceptor.ts +++ b/src/utils/http/interceptors/refreshToken.interceptor.ts @@ -72,6 +72,9 @@ export const refreshTokenInterceptor = (client: AxiosInstance) => { }); authStore.clear(); try { + // console.error( + // "[AUTH] Unauthorized dispatched from refreshToken.interceptor" + // ); window.dispatchEvent(new CustomEvent('app:unauthorized')); } catch (e) { window.location.href = "/login";