mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-24 17:10:12 +08:00
Improve docx preview (#15907)
This commit is contained in:
@@ -1,134 +1,242 @@
|
||||
import message from '@/components/ui/message';
|
||||
import { Spin } from '@/components/ui/spin';
|
||||
import request from '@/utils/request';
|
||||
import { DocxEditorViewer, useDocxEditor } from '@extend-ai/react-docx';
|
||||
import classNames from 'classnames';
|
||||
import mammoth from 'mammoth';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface DocPreviewerProps {
|
||||
className?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
// Word document preview component. Behavior:
|
||||
// 1) Fetches the document as a Blob.
|
||||
// 2) Detects .docx input via a ZIP header probe.
|
||||
// 3) Renders .docx using Mammoth; presents a controlled "unsupported" notice for non-ZIP payloads.
|
||||
// ZIP file header bytes "PK"
|
||||
const ZIP_HEADER_0 = 0x50;
|
||||
const ZIP_HEADER_1 = 0x4b;
|
||||
|
||||
const isZipLikeBlob = async (blob: Blob): Promise<boolean> => {
|
||||
try {
|
||||
const headerSlice = blob.slice(0, 4);
|
||||
const buf = await headerSlice.arrayBuffer();
|
||||
const bytes = new Uint8Array(buf);
|
||||
return (
|
||||
bytes.length >= 2 &&
|
||||
bytes[0] === ZIP_HEADER_0 &&
|
||||
bytes[1] === ZIP_HEADER_1
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Failed to inspect blob header', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const ZOOM_STEPS = [25, 50, 75, 100, 125, 150, 175, 200] as const;
|
||||
|
||||
const clampZoom = (scale: number, direction: 1 | -1): number => {
|
||||
let idx = ZOOM_STEPS.indexOf(scale as (typeof ZOOM_STEPS)[number]);
|
||||
if (idx < 0) {
|
||||
if (direction > 0) {
|
||||
idx = ZOOM_STEPS.findIndex((v) => v > scale);
|
||||
} else {
|
||||
for (let i = ZOOM_STEPS.length - 1; i >= 0; i--) {
|
||||
if (ZOOM_STEPS[i] < scale) {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
idx = Math.max(
|
||||
0,
|
||||
Math.min(ZOOM_STEPS.length - 1, idx < 0 ? 0 : idx + direction),
|
||||
);
|
||||
return ZOOM_STEPS[idx] ?? scale;
|
||||
};
|
||||
|
||||
// Word document preview component.
|
||||
// Uses @extend-ai/react-docx for canvas-based page-level rendering.
|
||||
// Falls back to an unsupported notice for legacy .doc (non-ZIP) payloads.
|
||||
export const DocPreviewer: React.FC<DocPreviewerProps> = ({
|
||||
className,
|
||||
url,
|
||||
}) => {
|
||||
const [htmlContent, setHtmlContent] = useState<string>('');
|
||||
const editor = useDocxEditor({ initialFileName: 'document.docx' });
|
||||
const { importDocxFile, status, totalPages } = editor;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [zoomScale, setZoomScale] = useState(100);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
// Determines whether the Blob represents a .docx document by checking for the ZIP
|
||||
// file signature ("PK") in the initial bytes. A valid .docx file is a ZIP container
|
||||
// and always begins with:
|
||||
// 50 4B 03 04 ("PK..")
|
||||
//
|
||||
// Legacy .doc files use the CFBF binary format, commonly starting with:
|
||||
// D0 CF 11 E0 A1 B1 1A E1
|
||||
//
|
||||
// Note that some files distributed with a “.doc” extension may internally be .docx
|
||||
// documents (e.g., renamed files or files produced by systems that export .docx
|
||||
// content under a .doc filename). These files will still present the ZIP signature
|
||||
// and are therefore treated as supported .docx payloads. The header inspection
|
||||
// ensures correct routing regardless of filename or reported extension.
|
||||
const isZipLikeBlob = async (blob: Blob): Promise<boolean> => {
|
||||
try {
|
||||
const headerSlice = blob.slice(0, 4);
|
||||
const buf = await headerSlice.arrayBuffer();
|
||||
const bytes = new Uint8Array(buf);
|
||||
|
||||
// ZIP files start with "PK" (0x50, 0x4B)
|
||||
return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b;
|
||||
} catch (e) {
|
||||
console.error('Failed to inspect blob header', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDocument = async () => {
|
||||
// Fetch the document blob and load it into the editor
|
||||
const fetchDocument = useCallback(async () => {
|
||||
if (!url) return;
|
||||
|
||||
cancelledRef.current = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await request(url, {
|
||||
method: 'GET',
|
||||
responseType: 'blob',
|
||||
onError: () => {
|
||||
message.error('Document parsing failed');
|
||||
console.error('Error loading document:', url);
|
||||
},
|
||||
});
|
||||
let res;
|
||||
try {
|
||||
res = await request(url, {
|
||||
method: 'GET',
|
||||
responseType: 'blob',
|
||||
onError: () => {
|
||||
if (!cancelledRef.current) {
|
||||
message.error('Document parsing failed');
|
||||
console.error('Error loading document:', url);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
if (!cancelledRef.current) {
|
||||
setError('Failed to fetch document.');
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
try {
|
||||
const blob: Blob = res.data;
|
||||
const contentType: string =
|
||||
blob.type || (res as any).headers?.['content-type'] || '';
|
||||
|
||||
// Execution path selection: ZIP-like payloads are treated as .docx and rendered via Mammoth;
|
||||
// non-ZIP payloads receive an explicit unsupported notice.
|
||||
const looksLikeZip = await isZipLikeBlob(blob);
|
||||
|
||||
if (!looksLikeZip) {
|
||||
// Non-ZIP payload (likely legacy .doc or another format): skip Mammoth processing.
|
||||
setHtmlContent(`
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<div class="border border-dashed border-border-normal rounded-xl p-8 max-w-2xl text-center">
|
||||
<p class="text-2xl font-bold mb-4">
|
||||
Preview is not available for this Word document
|
||||
</p>
|
||||
<p class="italic text-sm text-muted-foreground leading-relaxed">
|
||||
Mammoth supports modern <code>.docx</code> files only.<br/>
|
||||
The file header does not indicate a <code>.docx</code> ZIP archive.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
setError(
|
||||
'This file header does not indicate a .docx ZIP archive. Only .docx files are supported.',
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// ZIP-like payload: parse as .docx with Mammoth
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const result = await mammoth.convertToHtml(
|
||||
{ arrayBuffer },
|
||||
{ includeDefaultStyleMap: true },
|
||||
);
|
||||
const file = new File([blob], 'document.docx', {
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
});
|
||||
|
||||
const styledContent = result.value
|
||||
.replace(/<p>/g, '<p class="mb-2">')
|
||||
.replace(/<h(\d)>/g, '<h$1 class="font-semibold mt-4 mb-2">');
|
||||
await importDocxFile(file);
|
||||
|
||||
setHtmlContent(styledContent);
|
||||
if (!cancelledRef.current) {
|
||||
setZoomScale(100);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('Failed to parse document.');
|
||||
console.error('Error parsing document:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!cancelledRef.current) {
|
||||
message.error('Failed to parse document.');
|
||||
console.error('Error parsing document:', err);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [url, importDocxFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (url) {
|
||||
fetchDocument();
|
||||
fetchDocument();
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
};
|
||||
}, [fetchDocument]);
|
||||
|
||||
// Monitor editor status for library-level errors
|
||||
useEffect(() => {
|
||||
if (status === 'Only .docx files are supported') {
|
||||
setError(status);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [url]);
|
||||
}, [status]);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setZoomScale((s) => clampZoom(s, 1));
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setZoomScale((s) => clampZoom(s, -1));
|
||||
}, []);
|
||||
|
||||
const showContent = !loading && !error;
|
||||
const pageCount = showContent && totalPages > 0 ? totalPages : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'relative w-full h-full p-4 bg-background-paper border border-border-normal rounded-md overflow-auto',
|
||||
'relative w-full h-full flex flex-col bg-background-paper border border-border-normal rounded-md overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Spin />
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between shrink-0 px-4 py-2 border-b border-border-normal bg-background-paper">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{loading ? 'Loading...' : error ? '' : `Page ${pageCount || '-'}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || !!error || zoomScale <= ZOOM_STEPS[0]}
|
||||
className="p-1 rounded hover:bg-gray-100 disabled:opacity-30 transition-opacity"
|
||||
onClick={handleZoomOut}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm w-12 text-center tabular-nums select-none">
|
||||
{zoomScale}%
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
loading ||
|
||||
!!error ||
|
||||
zoomScale >= ZOOM_STEPS[ZOOM_STEPS.length - 1]
|
||||
}
|
||||
className="p-1 rounded hover:bg-gray-100 disabled:opacity-30 transition-opacity"
|
||||
onClick={handleZoomIn}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!loading && <div dangerouslySetInnerHTML={{ __html: htmlContent }} />}
|
||||
{/* Viewer / Error area */}
|
||||
<div className="relative flex-1 overflow-auto bg-background-paper">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div className="flex items-center justify-center h-full p-8">
|
||||
<div className="border border-dashed border-border-normal rounded-xl p-8 max-w-2xl text-center">
|
||||
<p className="text-2xl font-bold mb-4">
|
||||
Preview is not available for this Word document
|
||||
</p>
|
||||
<p className="italic text-sm text-muted-foreground leading-relaxed">
|
||||
@extend-ai/react-docx supports modern <code>.docx</code> files
|
||||
only.
|
||||
<br />
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showContent && (
|
||||
<div className="flex justify-center p-4">
|
||||
<div style={{ zoom: zoomScale / 100 }}>
|
||||
<DocxEditorViewer
|
||||
editor={editor}
|
||||
mode="read-only"
|
||||
loadingState={
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Spin />
|
||||
</div>
|
||||
}
|
||||
pageGapBackgroundColor="#f5f5f5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import { getAuthorization } from '@/utils/authorization-util';
|
||||
import jsPreviewExcel from '@js-preview/excel';
|
||||
import { useSize } from 'ahooks';
|
||||
import axios from 'axios';
|
||||
import mammoth from 'mammoth';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
export const useDocumentResizeObserver = () => {
|
||||
@@ -127,45 +126,6 @@ export const useFetchExcel = (filePath: string) => {
|
||||
return { status, containerRef, error };
|
||||
};
|
||||
|
||||
export const useFetchDocx = (filePath: string) => {
|
||||
const [succeed, setSucceed] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
const { fetchDocument } = useFetchDocument();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchDocumentAsync = useCallback(async () => {
|
||||
try {
|
||||
const jsonFile = await fetchDocument(filePath);
|
||||
mammoth
|
||||
.convertToHtml(
|
||||
{ arrayBuffer: jsonFile.data },
|
||||
{ includeDefaultStyleMap: true },
|
||||
)
|
||||
.then((result) => {
|
||||
setSucceed(true);
|
||||
const docEl = document.createElement('div');
|
||||
docEl.className = 'document-container';
|
||||
docEl.innerHTML = result.value;
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
container.innerHTML = docEl.outerHTML;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setSucceed(false);
|
||||
});
|
||||
} catch (error: any) {
|
||||
setError(error.toString());
|
||||
}
|
||||
}, [filePath, fetchDocument]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocumentAsync();
|
||||
}, [fetchDocumentAsync]);
|
||||
|
||||
return { succeed, containerRef, error };
|
||||
};
|
||||
|
||||
export const useCatchDocumentError = (url: string) => {
|
||||
const httpHeaders = useMemo(() => {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user