diff --git a/web/package-lock.json b/web/package-lock.json index 8e4891021b..c7a7b0047b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -84,6 +84,7 @@ "js-base64": "^3.7.5", "jsencrypt": "^3.3.2", "jsoneditor": "^10.4.2", + "jszip": "^3.10.1", "lexical": "^0.45.0", "lodash": "^4.18.1", "lucide-react": "^1.7.0", @@ -144,6 +145,7 @@ "@testing-library/react": "^15.0.7", "@types/dompurify": "^3.0.5", "@types/jest": "^29.5.12", + "@types/jszip": "^3.4.0", "@types/lodash": "^4.14.202", "@types/node": "^24.3.0", "@types/react": "^18.0.33", @@ -3286,9 +3288,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3306,9 +3305,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3326,9 +3322,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3346,9 +3339,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3366,9 +3356,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3386,9 +3373,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3406,9 +3390,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3426,9 +3407,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3633,9 +3611,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3653,9 +3628,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3673,9 +3645,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3693,9 +3662,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3713,9 +3679,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3733,9 +3696,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3753,9 +3713,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3773,9 +3730,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9155,6 +9109,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jszip": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/@types/jszip/-/jszip-3.4.0.tgz", + "integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jszip": "*" + } + }, "node_modules/@types/katex": { "version": "0.16.7", "resolved": "https://registry.npmmirror.com/@types/katex/-/katex-0.16.7.tgz", diff --git a/web/package.json b/web/package.json index 08c3ce89ca..df316ad15c 100644 --- a/web/package.json +++ b/web/package.json @@ -94,6 +94,7 @@ "js-base64": "^3.7.5", "jsencrypt": "^3.3.2", "jsoneditor": "^10.4.2", + "jszip": "^3.10.1", "lexical": "^0.45.0", "lodash": "^4.18.1", "lucide-react": "^1.7.0", @@ -154,6 +155,7 @@ "@testing-library/react": "^15.0.7", "@types/dompurify": "^3.0.5", "@types/jest": "^29.5.12", + "@types/jszip": "^3.4.0", "@types/lodash": "^4.14.202", "@types/node": "^24.3.0", "@types/react": "^18.0.33", @@ -165,8 +167,6 @@ "@types/webpack-env": "^1.18.4", "@vitejs/plugin-react": "^5.1.2", "autoprefixer": "^10.4.21", - "oxfmt": "^0.60.0", - "oxlint": "^1.75.0", "cross-env": "^7.0.3", "esbuild-jest": "^0.5.0", "html-loader": "^5.1.0", @@ -174,6 +174,8 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "less": "^4.4.2", + "oxfmt": "^0.60.0", + "oxlint": "^1.75.0", "postcss": "^8.5.6", "postcss-loader": "^8.2.0", "react-dev-inspector": "^2.0.1", diff --git a/web/src/components/document-preview/hooks.ts b/web/src/components/document-preview/hooks.ts index af05cdd53c..5066ed7920 100644 --- a/web/src/components/document-preview/hooks.ts +++ b/web/src/components/document-preview/hooks.ts @@ -4,8 +4,9 @@ import { useGetPipelineResultSearchParams } from '@/pages/dataflow-result/hooks' import api, { restAPIv1 } from '@/utils/api'; import { getAuthorization } from '@/utils/authorization-util'; import jsPreviewExcel from '@js-preview/excel'; -import { useSize } from 'ahooks'; +import { useDebounceFn, useSize } from 'ahooks'; import axios from 'axios'; +import JSZip from 'jszip'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; // ZIP file header bytes "PK" @@ -114,36 +115,152 @@ export const useFetchDocument = () => { return { fetchDocument }; }; +/** + * WPS spreadsheets embed images in cells via the proprietary DISPIMG formula + * (stored in xl/cellimages.xml). @js-preview/excel cannot evaluate this + * formula and crashes with "Cannot read properties of undefined (reading + * 'render')". This function strips DISPIMG formulas from worksheet XML, + * replacing them with a text placeholder so the rest of the sheet renders. + */ +async function stripWpsDispImg(data: ArrayBuffer): Promise { + const zip = await JSZip.loadAsync(data); + + const worksheetPaths = Object.keys(zip.files).filter((path) => + /^xl\/worksheets\/sheet\d+\.xml$/.test(path), + ); + + let modified = false; + for (const path of worksheetPaths) { + const file = zip.file(path); + if (!file) continue; + const xml = await file.async('string'); + if (!xml.includes('DISPIMG')) continue; + + modified = true; + let cleaned = xml; + // Remove formula tags that reference DISPIMG + cleaned = cleaned.replace( + /]*>[\s\S]*?DISPIMG[\s\S]*?<\/f>/g, + '', + ); + // Replace cached values that reference DISPIMG with a placeholder + cleaned = cleaned.replace( + /[^<]*DISPIMG[^<]*<\/v>/g, + '[图片]', + ); + zip.file(path, cleaned); + } + + if (!modified) return data; + return zip.generateAsync({ + type: 'arraybuffer', + compression: 'DEFLATE', + }); +} + export const useFetchExcel = (filePath: string) => { const [status, setStatus] = useState(true); const { fetchDocument } = useFetchDocument(); - const containerRef = useRef(null); + const [containerEl, setContainerEl] = useState(null); + const size = useSize(containerEl); const { error } = useCatchError(filePath); - const fetchDocumentAsync = useCallback(async () => { - let myExcelPreviewer; - if (containerRef.current) { - myExcelPreviewer = jsPreviewExcel.init(containerRef.current); + const previewerRef = useRef | null>( + null, + ); + // Cache the fetched ArrayBuffer so we don't re-fetch on every resize. + const dataRef = useRef(null); + const [dataReady, setDataReady] = useState(false); + + // @js-preview/excel reads the container's width/height at init time and + // exposes no public resize method, so the only way to make the spreadsheet + // track container size changes is to destroy + re-init the previewer. + const renderPreview = useCallback(async () => { + if (!containerEl || !dataRef.current) return; + + if (previewerRef.current) { + previewerRef.current.destroy(); + previewerRef.current = null; } - const jsonFile = await fetchDocument(filePath); - myExcelPreviewer - ?.preview(jsonFile.data) - .then(() => { - setStatus(true); - }) - .catch((e) => { - // oxlint-disable-next-line no-console - console.warn('failed', e); - myExcelPreviewer.destroy(); - setStatus(false); - }); + + const previewer = jsPreviewExcel.init(containerEl); + previewerRef.current = previewer; + + try { + await previewer.preview(dataRef.current); + setStatus(true); + } catch (e) { + // oxlint-disable-next-line no-console + console.warn('failed', e); + previewer.destroy(); + previewerRef.current = null; + setStatus(false); + } + }, [containerEl]); + + // Leading edge renders immediately on first data/size, trailing edge + // collapses rapid resize bursts into a single re-render. + const { run: debouncedRender } = useDebounceFn(renderPreview, { + wait: 150, + leading: true, + trailing: true, + }); + + // Fetch the file once per url. On url change, drop cached data and the + // existing previewer so the next render starts from a clean state. + useEffect(() => { + if (!filePath) return; + + setDataReady(false); + setStatus(true); + if (previewerRef.current) { + previewerRef.current.destroy(); + previewerRef.current = null; + } + dataRef.current = null; + + let cancelled = false; + (async () => { + try { + const jsonFile = await fetchDocument(filePath); + if (cancelled) return; + try { + dataRef.current = await stripWpsDispImg(jsonFile.data); + } catch { + // Not a valid ZIP or preprocessing failed — use original data + dataRef.current = jsonFile.data; + } + setDataReady(true); + } catch (e) { + if (!cancelled) { + // oxlint-disable-next-line no-console + console.warn('failed to fetch', e); + setStatus(false); + } + } + })(); + return () => { + cancelled = true; + }; }, [filePath, fetchDocument]); + // Initial render + debounced re-render on container resize. useEffect(() => { - fetchDocumentAsync(); - }, [fetchDocumentAsync]); + if (!dataReady || !containerEl) return; + debouncedRender(); + }, [dataReady, containerEl, size?.width, size?.height, debouncedRender]); - return { status, containerRef, error }; + // Tear down the previewer on unmount. + useEffect(() => { + return () => { + if (previewerRef.current) { + previewerRef.current.destroy(); + previewerRef.current = null; + } + }; + }, []); + + return { status, containerRef: setContainerEl, error }; }; export const useCatchDocumentError = (url: string) => {