From bf64faf1072e76aa0ad8352cc5aaed0b6855b19d Mon Sep 17 00:00:00 2001 From: zhangzheng <826193720@qq.com> Date: Wed, 5 Aug 2026 10:21:52 +0800 Subject: [PATCH] fix tiff display --- pnpm-lock.yaml | 61 +++++--------- src/web-ui/package.json | 2 + .../src/shared/utils/imageDataUrl.test.ts | 54 +++++++++++++ src/web-ui/src/shared/utils/imageDataUrl.ts | 81 +++++++++++++++++++ .../tools/editor/components/ImageViewer.tsx | 44 ++++------ .../editor/meditor/utils/loadLocalImages.ts | 5 +- .../meditor/utils/rehype-local-images.ts | 17 +--- 7 files changed, 179 insertions(+), 85 deletions(-) create mode 100644 src/web-ui/src/shared/utils/imageDataUrl.test.ts create mode 100644 src/web-ui/src/shared/utils/imageDataUrl.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ecce9a736..26fc5e6b5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -296,6 +296,9 @@ importers: unist-util-visit: specifier: ^5.0.0 version: 5.1.0 + utif: + specifier: ^3.1.0 + version: 3.1.0 yaml: specifier: ^2.8.2 version: 2.8.2 @@ -324,6 +327,9 @@ importers: '@types/react-syntax-highlighter': specifier: ^15.5.13 version: 15.5.13 + '@types/utif': + specifier: ^3.0.6 + version: 3.0.6 '@vitejs/plugin-react': specifier: ^4.6.0 version: 4.7.0(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -889,105 +895,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1278,42 +1268,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -1403,79 +1387,66 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -1555,35 +1526,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.10.0': resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.10.0': resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.10.0': resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.10.0': resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.10.0': resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==} @@ -2026,6 +1992,9 @@ packages: '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/utif@3.0.6': + resolution: {integrity: sha512-4XHDJkLHoUeLA2P/VOd6dpDJpSTpV84tN9EyHojZrW22jtUR0VrdLwVRUAw+oI3g72vSzOo+YQOzfwcW6WYHLw==} + '@types/which@2.0.2': resolution: {integrity: sha512-113D3mDkZDjo+EeUEHCFy0qniNc1ZpecGiAU7WSo7YDoSzolZIQKpYFHrPpjkB2nuyahcKfrmLXeQlh7gqJYdw==} @@ -5247,11 +5216,15 @@ packages: resolution: {integrity: sha512-5cnLm4gseXjAclKowC4IjByaGsjtAoV6PrOQOljplNB54ReUYJP8HdAFq2muHinSDAh09PPX/uXDPfdxRHvuSA==} engines: {node: '>= 0.8.0'} + utif@3.1.0: + resolution: {integrity: sha512-WEo4D/xOvFW53K5f5QTaTbbiORcm2/pCL9P6qmJnup+17eYfKaEhDeX9PeQkuyEoIxlbGklDuGl8xwuXYMrrXQ==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@11.1.0: @@ -7149,6 +7122,10 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} + '@types/utif@3.0.6': + dependencies: + '@types/node': 20.19.37 + '@types/which@2.0.2': {} '@types/ws@8.18.1': @@ -11305,6 +11282,10 @@ snapshots: userhome@1.0.1: {} + utif@3.1.0: + dependencies: + pako: 1.0.11 + util-deprecate@1.0.2: {} uuid@10.0.0: {} diff --git a/src/web-ui/package.json b/src/web-ui/package.json index 628764bb49..691f1069ad 100644 --- a/src/web-ui/package.json +++ b/src/web-ui/package.json @@ -68,6 +68,7 @@ "remark-rehype": "^11.1.2", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", + "utif": "^3.1.0", "yaml": "^2.8.2", "zustand": "^5.0.10" }, @@ -79,6 +80,7 @@ "@types/react": "^18.3.29", "@types/react-dom": "^18.3.0", "@types/react-syntax-highlighter": "^15.5.13", + "@types/utif": "^3.0.6", "@vitejs/plugin-react": "^4.6.0", "eslint": "^9.24.0", "eslint-plugin-react-hooks": "^5.2.0", diff --git a/src/web-ui/src/shared/utils/imageDataUrl.test.ts b/src/web-ui/src/shared/utils/imageDataUrl.test.ts new file mode 100644 index 0000000000..a5010e3ea4 --- /dev/null +++ b/src/web-ui/src/shared/utils/imageDataUrl.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from 'vitest'; +import { createBrowserImageDataUrl, getImageMimeType, isTiffPath } from './imageDataUrl'; + +vi.mock('utif', () => ({ + decode: vi.fn(() => [{ width: 1, height: 1, data: new Uint8Array() }]), + decodeImage: vi.fn(), + toRGBA8: vi.fn(() => new Uint8Array([255, 0, 0, 255])), +})); + +describe('imageDataUrl', () => { + it('recognizes both TIFF extensions', () => { + expect(isTiffPath('image.tif')).toBe(true); + expect(isTiffPath('IMAGE.TIFF?version=1')).toBe(true); + expect(isTiffPath('image.png')).toBe(false); + }); + + it('returns the TIFF MIME type', () => { + expect(getImageMimeType('image.tif')).toBe('image/tiff'); + expect(getImageMimeType('image.tiff')).toBe('image/tiff'); + }); + + it('keeps browser-supported images as base64 data URLs', async () => { + await expect(createBrowserImageDataUrl('image.png', 'AQID')).resolves.toBe( + 'data:image/png;base64,AQID', + ); + }); + + it('decodes TIFF content and renders it as a PNG data URL', async () => { + const putImageData = vi.fn(); + const toDataURL = vi.fn(() => 'data:image/png;base64,converted'); + class MockImageData { + constructor( + public data: Uint8ClampedArray, + public width: number, + public height: number, + ) {} + } + vi.stubGlobal('ImageData', MockImageData); + vi.spyOn(document, 'createElement').mockReturnValue({ + width: 0, + height: 0, + getContext: () => ({ putImageData }), + toDataURL, + } as unknown as HTMLCanvasElement); + + await expect(createBrowserImageDataUrl('image.tiff', 'AQID')).resolves.toBe( + 'data:image/png;base64,converted', + ); + expect(putImageData).toHaveBeenCalledOnce(); + expect(toDataURL).toHaveBeenCalledWith('image/png'); + }); +}); diff --git a/src/web-ui/src/shared/utils/imageDataUrl.ts b/src/web-ui/src/shared/utils/imageDataUrl.ts new file mode 100644 index 0000000000..77e15c59e0 --- /dev/null +++ b/src/web-ui/src/shared/utils/imageDataUrl.ts @@ -0,0 +1,81 @@ +const TIFF_EXTENSIONS = new Set(['tif', 'tiff']); + +export function isTiffPath(path: string): boolean { + const pathWithoutQueryOrHash = path.split(/[?#]/, 1)[0]; + const extension = pathWithoutQueryOrHash.toLowerCase().split('.').pop(); + return TIFF_EXTENSIONS.has(extension || ''); +} + +export function getImageMimeType(path: string): string { + const pathWithoutQueryOrHash = path.split(/[?#]/, 1)[0]; + const extension = pathWithoutQueryOrHash.toLowerCase().split('.').pop(); + const mimeTypes: Record = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + bmp: 'image/bmp', + webp: 'image/webp', + svg: 'image/svg+xml', + ico: 'image/x-icon', + avif: 'image/avif', + tif: 'image/tiff', + tiff: 'image/tiff', + }; + + return mimeTypes[extension || ''] || 'image/jpeg'; +} + +function base64ToArrayBuffer(base64Content: string): ArrayBuffer { + const binary = atob(base64Content.replace(/\s/g, '')); + const bytes = new Uint8Array(binary.length); + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + return bytes.buffer; +} + +async function tiffBase64ToPngDataUrl(base64Content: string): Promise { + const UTIF = await import('utif'); + const buffer = base64ToArrayBuffer(base64Content); + const ifds = UTIF.decode(buffer); + const firstPage = ifds[0]; + + if (!firstPage) { + throw new Error('TIFF image contains no pages'); + } + + UTIF.decodeImage(buffer, firstPage); + + if (!firstPage.width || !firstPage.height) { + throw new Error('TIFF image has invalid dimensions'); + } + + const rgba = UTIF.toRGBA8(firstPage); + const canvas = document.createElement('canvas'); + canvas.width = firstPage.width; + canvas.height = firstPage.height; + + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Failed to get canvas context'); + } + + context.putImageData( + new ImageData(new Uint8ClampedArray(rgba), firstPage.width, firstPage.height), + 0, + 0, + ); + + return canvas.toDataURL('image/png'); +} + +export async function createBrowserImageDataUrl(path: string, base64Content: string): Promise { + if (isTiffPath(path)) { + return tiffBase64ToPngDataUrl(base64Content); + } + + return `data:${getImageMimeType(path)};base64,${base64Content}`; +} diff --git a/src/web-ui/src/tools/editor/components/ImageViewer.tsx b/src/web-ui/src/tools/editor/components/ImageViewer.tsx index e8f6717380..1a47435abf 100644 --- a/src/web-ui/src/tools/editor/components/ImageViewer.tsx +++ b/src/web-ui/src/tools/editor/components/ImageViewer.tsx @@ -8,6 +8,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { ZoomIn, ZoomOut, RotateCw, Download, Maximize2 } from 'lucide-react'; import { createLogger } from '@/shared/utils/logger'; +import { createBrowserImageDataUrl } from '@/shared/utils/imageDataUrl'; import { Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import './ImageViewer.scss'; @@ -51,23 +52,9 @@ export const ImageViewer: React.FC = ({ const [isFullscreen, setIsFullscreen] = useState(false); const [fileSize, setFileSize] = useState(0); - const getMimeType = useCallback((path: string): string => { - const ext = path.toLowerCase().split('.').pop(); - const mimeTypes: Record = { - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'png': 'image/png', - 'gif': 'image/gif', - 'bmp': 'image/bmp', - 'webp': 'image/webp', - 'svg': 'image/svg+xml', - 'ico': 'image/x-icon', - 'avif': 'image/avif' - }; - return mimeTypes[ext || ''] || 'image/jpeg'; - }, []); - useEffect(() => { + let cancelled = false; + const loadImage = async () => { if (!filePath) { setError(t('editor.imageViewer.filePathEmpty')); @@ -83,24 +70,27 @@ export const ImageViewer: React.FC = ({ const { workspaceAPI } = await import('@/infrastructure/api'); const result = await workspaceAPI.readFileContent(filePath, 'base64'); + const dataUrl = await createBrowserImageDataUrl(filePath, result); - const mimeType = getMimeType(filePath); - - const dataUrl = `data:${mimeType};base64,${result}`; - - setImageUrl(dataUrl); - setFileSize(result.length); - setLoading(false); - + if (!cancelled) { + setImageUrl(dataUrl); + setFileSize(result.length); + setLoading(false); + } } catch (err) { log.error('Failed to load image', err); - setError(t('editor.imageViewer.loadImageFailedWithMessage', { message: String(err) })); - setLoading(false); + if (!cancelled) { + setError(t('editor.imageViewer.loadImageFailedWithMessage', { message: String(err) })); + setLoading(false); + } } }; loadImage(); - }, [filePath, getMimeType, t]); + return () => { + cancelled = true; + }; + }, [filePath, t]); const handleImageLoad = useCallback((e: React.SyntheticEvent) => { const img = e.currentTarget; diff --git a/src/web-ui/src/tools/editor/meditor/utils/loadLocalImages.ts b/src/web-ui/src/tools/editor/meditor/utils/loadLocalImages.ts index a0af6c41f4..220ee386e5 100644 --- a/src/web-ui/src/tools/editor/meditor/utils/loadLocalImages.ts +++ b/src/web-ui/src/tools/editor/meditor/utils/loadLocalImages.ts @@ -3,8 +3,8 @@ * After Markdown render, resolves images marked with `data-local-image`. */ -import { getMimeType } from './rehype-local-images'; import { createLogger } from '@/shared/utils/logger'; +import { createBrowserImageDataUrl } from '@/shared/utils/imageDataUrl'; import { i18nService } from '@/infrastructure/i18n'; const log = createLogger('loadLocalImages'); @@ -36,8 +36,7 @@ async function getLocalImageDataUrl(localPath: string): Promise { const request = (async () => { const { workspaceAPI } = await import('@/infrastructure/api'); const base64Content = await workspaceAPI.readFileContent(localPath); - const mimeType = getMimeType(localPath); - const dataUrl = `data:${mimeType};base64,${base64Content}`; + const dataUrl = await createBrowserImageDataUrl(localPath, base64Content); localImageDataUrlCache.set(localPath, dataUrl); localImageRequestCache.delete(localPath); diff --git a/src/web-ui/src/tools/editor/meditor/utils/rehype-local-images.ts b/src/web-ui/src/tools/editor/meditor/utils/rehype-local-images.ts index cf36878b85..63c54f920e 100644 --- a/src/web-ui/src/tools/editor/meditor/utils/rehype-local-images.ts +++ b/src/web-ui/src/tools/editor/meditor/utils/rehype-local-images.ts @@ -4,6 +4,7 @@ */ import { visit } from 'unist-util-visit'; import { createLogger } from '@/shared/utils/logger'; +import { getImageMimeType } from '@/shared/utils/imageDataUrl'; type Root = any; type Element = any; @@ -90,21 +91,7 @@ export function resolveImagePath(src: string, basePath?: string): string { /** * Get image MIME type. */ -export function getMimeType(path: string): string { - const ext = path.toLowerCase().split('.').pop(); - const mimeTypes: Record = { - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'png': 'image/png', - 'gif': 'image/gif', - 'bmp': 'image/bmp', - 'webp': 'image/webp', - 'svg': 'image/svg+xml', - 'ico': 'image/x-icon', - 'avif': 'image/avif' - }; - return mimeTypes[ext || ''] || 'image/jpeg'; -} +export const getMimeType = getImageMimeType; /** * Rehype plugin: mark local image paths.