fix(document-preview): strip WPS DISPIMG formula from xlsx before preview (#17912)

This commit is contained in:
chanx
2026-08-06 11:13:44 +08:00
committed by GitHub
parent e35956bcc4
commit bda74135b5
3 changed files with 154 additions and 71 deletions

60
web/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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<ArrayBuffer> {
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 <f> formula tags that reference DISPIMG
cleaned = cleaned.replace(
/<f\b[^>]*>[\s\S]*?DISPIMG[\s\S]*?<\/f>/g,
'',
);
// Replace cached <v> values that reference DISPIMG with a placeholder
cleaned = cleaned.replace(
/<v>[^<]*DISPIMG[^<]*<\/v>/g,
'<v>[图片]</v>',
);
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<HTMLDivElement>(null);
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(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<ReturnType<typeof jsPreviewExcel.init> | null>(
null,
);
// Cache the fetched ArrayBuffer so we don't re-fetch on every resize.
const dataRef = useRef<ArrayBuffer | null>(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) => {