From b7ead65f1db3a14ab71e695c507d840ba0827388 Mon Sep 17 00:00:00 2001 From: MT-Mint <798521692@qq.com> Date: Sat, 11 Apr 2026 16:23:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E6=8E=A5=E5=85=A5=20pdf.js?= =?UTF-8?q?=20=E9=A2=84=E8=A7=88=E5=B9=B6=E8=B0=83=E6=95=B4=E4=BA=A7?= =?UTF-8?q?=E7=89=A9=E9=A2=84=E8=A7=88=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 137 +++++++++++ .../artifacts/artifact-file-detail.tsx | 223 +++++++++++++----- ...tifact-generation-preview-download.spec.ts | 2 +- 4 files changed, 303 insertions(+), 60 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 112a8761..1ca3351d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -81,6 +81,7 @@ "nextra-theme-docs": "^4.6.1", "nuxt-og-image": "^5.1.13", "ogl": "^1.0.11", + "pdfjs-dist": "^5.6.205", "pptx-preview": "^1.0.7", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index a2baa23d..a7a3ec81 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -191,6 +191,9 @@ importers: ogl: specifier: ^1.0.11 version: 1.0.11 + pdfjs-dist: + specifier: ^5.6.205 + version: 5.6.205 pptx-preview: specifier: ^1.0.7 version: 1.0.7 @@ -963,6 +966,77 @@ packages: '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + '@napi-rs/canvas-android-arm64@0.1.97': + resolution: {integrity: sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.97': + resolution: {integrity: sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.97': + resolution: {integrity: sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.97': + resolution: {integrity: sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.97': + resolution: {integrity: sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-arm64-musl@0.1.97': + resolution: {integrity: sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.97': + resolution: {integrity: sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.97': + resolution: {integrity: sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.97': + resolution: {integrity: sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.97': + resolution: {integrity: sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.97': + resolution: {integrity: sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.97': + resolution: {integrity: sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==} + engines: {node: '>= 10'} + '@napi-rs/simple-git-android-arm-eabi@0.1.22': resolution: {integrity: sha512-JQZdnDNm8o43A5GOzwN/0Tz3CDBQtBUNqzVwEopm32uayjdjxev1Csp1JeaqF3v9djLDIvsSE39ecsN2LhCKKQ==} engines: {node: '>= 10'} @@ -4605,6 +4679,9 @@ packages: node-mock-http@1.0.4: resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + node-readable-to-web-readable-stream@0.4.2: + resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -4789,6 +4866,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pdfjs-dist@5.6.205: + resolution: {integrity: sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==} + engines: {node: '>=20.19.0 || >=22.13.0 || >=24'} + perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} @@ -6751,6 +6832,54 @@ snapshots: dependencies: langium: 3.3.1 + '@napi-rs/canvas-android-arm64@0.1.97': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.97': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.97': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.97': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.97': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.97': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.97': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.97': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.97': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.97': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.97': + optional: true + + '@napi-rs/canvas@0.1.97': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.97 + '@napi-rs/canvas-darwin-arm64': 0.1.97 + '@napi-rs/canvas-darwin-x64': 0.1.97 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.97 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.97 + '@napi-rs/canvas-linux-arm64-musl': 0.1.97 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.97 + '@napi-rs/canvas-linux-x64-gnu': 0.1.97 + '@napi-rs/canvas-linux-x64-musl': 0.1.97 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.97 + '@napi-rs/canvas-win32-x64-msvc': 0.1.97 + optional: true + '@napi-rs/simple-git-android-arm-eabi@0.1.22': optional: true @@ -10952,6 +11081,9 @@ snapshots: node-mock-http@1.0.4: {} + node-readable-to-web-readable-stream@0.4.2: + optional: true + normalize-path@3.0.0: {} npm-run-path@5.3.0: @@ -11205,6 +11337,11 @@ snapshots: pathe@2.0.3: {} + pdfjs-dist@5.6.205: + optionalDependencies: + '@napi-rs/canvas': 0.1.97 + node-readable-to-web-readable-stream: 0.4.2 + perfect-debounce@2.1.0: {} performance-now@2.1.0: diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index 8554fb6f..b375d045 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -112,7 +112,7 @@ export function ArtifactFileDetail({ }, [filepath]); const artifactViewerSrcDoc = useMemo(() => { if (!artifactUrl) { - return ""; + return undefined; } return buildArtifactViewerSrcDoc({ artifactUrl, @@ -120,6 +120,11 @@ export function ArtifactFileDetail({ kind: artifactPreviewKind, }); }, [artifactUrl, fileName, artifactPreviewKind]); + // Native PDF iframe rendering is intentionally disabled; PDFs are rendered via pdf.js. + const artifactViewerSrc = useMemo(() => { + return undefined; + }, []); + const artifactViewerSandbox = "allow-same-origin allow-scripts allow-downloads"; const { content } = useArtifactContent({ threadId, filepath: filepathFromProps, @@ -608,7 +613,13 @@ export function ArtifactFileDetail({ )} {!isCodeFile && ( - isOfficePreviewKind(artifactPreviewKind) ? ( + artifactPreviewKind === "pdf" ? ( + + ) : isOfficePreviewKind(artifactPreviewKind) ? ( ) @@ -841,6 +853,144 @@ function PreviewIframe({ ); } +function ArtifactPdfPreview({ + className, + artifactUrl, + fileName, +}: { + className?: string; + artifactUrl: string; + fileName: string; +}) { + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [pageCount, setPageCount] = useState(0); + const containerRef = useRef(null); + + useEffect(() => { + let disposed = false; + + async function renderPdf() { + if (!artifactUrl || !containerRef.current) return; + + setIsLoading(true); + setError(null); + setPageCount(0); + containerRef.current.innerHTML = ""; + + try { + const response = await fetch(artifactUrl); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.arrayBuffer(); + + const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs"); + pdfjs.GlobalWorkerOptions.workerSrc = new URL( + "pdfjs-dist/legacy/build/pdf.worker.min.mjs", + import.meta.url, + ).toString(); + + const loadingTask = pdfjs.getDocument({ data }); + const pdf = await loadingTask.promise; + if (disposed || !containerRef.current) { + await loadingTask.destroy(); + return; + } + + setPageCount(pdf.numPages); + + const hostWidth = Math.max( + 640, + Math.min(containerRef.current.clientWidth || 960, 1200), + ); + const dpr = window.devicePixelRatio || 1; + + for (let pageNum = 1; pageNum <= pdf.numPages; pageNum += 1) { + if (disposed || !containerRef.current) break; + + const page = await pdf.getPage(pageNum); + const baseViewport = page.getViewport({ scale: 1 }); + const scale = (hostWidth - 32) / baseViewport.width; + const viewport = page.getViewport({ scale }); + + const pageWrapper = document.createElement("div"); + pageWrapper.className = + "mx-auto mb-4 w-fit rounded-md border border-[#e4e7ec] bg-white p-2 shadow-sm"; + + const canvas = document.createElement("canvas"); + canvas.style.width = `${viewport.width}px`; + canvas.style.height = `${viewport.height}px`; + canvas.width = Math.floor(viewport.width * dpr); + canvas.height = Math.floor(viewport.height * dpr); + + const context = canvas.getContext("2d"); + if (!context) continue; + context.scale(dpr, dpr); + + pageWrapper.appendChild(canvas); + containerRef.current.appendChild(pageWrapper); + + const renderTask = page.render({ + canvas, + canvasContext: context, + viewport, + }); + await renderTask.promise; + } + } catch (err) { + console.error("Failed to render pdf preview:", err); + if (!disposed) { + setError("无法预览该 PDF 文件,请下载后查看。"); + } + } finally { + if (!disposed) { + setIsLoading(false); + } + } + } + + void renderPdf(); + + return () => { + disposed = true; + }; + }, [artifactUrl]); + + if (error) { + return ( +
+
+

{fileName}

+

{error}

+ + 在新标签页打开 + +
+
+ ); + } + + return ( +
+
+ {pageCount > 0 ? `${fileName} · ${pageCount} page(s)` : fileName} +
+
+ {isLoading && ( +
+ +
+ )} +
+ ); +} + function ArtifactOfficePreview({ className, kind, @@ -858,7 +1008,6 @@ function ArtifactOfficePreview({ const [sheetNames, setSheetNames] = useState([]); const [activeSheet, setActiveSheet] = useState(""); const docxContainerRef = useRef(null); - const pptxContainerRef = useRef(null); const workbookRef = useRef(null); const canRenderDocx = kind === "docx"; @@ -972,54 +1121,12 @@ function ArtifactOfficePreview({ } }, [activeSheet, canRenderXlsx]); useEffect(() => { - let disposed = false; - - type PptxPreviewModule = { - init: ( - container: HTMLElement, - options: { width: number; height: number }, - ) => { - preview: (buffer: ArrayBuffer) => Promise | void; - }; - }; - - async function renderPptx() { - if (!canRenderPptx || !artifactUrl || !pptxContainerRef.current) { - return; - } - setIsLoading(true); - setError(null); - try { - const response = await fetch(artifactUrl); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - const bytes = await response.arrayBuffer(); - const pptxModule = (await import("pptx-preview")) as unknown as PptxPreviewModule; - if (disposed || !pptxContainerRef.current) { - return; - } - const container = pptxContainerRef.current; - container.innerHTML = ""; - const previewer = pptxModule.init(container, { width: 960, height: 540 }); - await Promise.resolve(previewer.preview(bytes)); - } catch (err) { - console.error("Failed to render pptx preview:", err); - if (!disposed) { - setError("无法预览该 PPT 文件。"); - } - } finally { - if (!disposed) { - setIsLoading(false); - } - } + if (!canRenderPptx) { + return; } - - void renderPptx(); - return () => { - disposed = true; - }; - }, [artifactUrl, canRenderPptx]); + setIsLoading(false); + setError("请下载ppt文件以获得最佳效果"); + }, [canRenderPptx]); return (
@@ -1056,12 +1163,6 @@ function ArtifactOfficePreview({ dangerouslySetInnerHTML={{ __html: xlsxHtml }} /> )} - {canRenderPptx && ( -
- )}
{error && ( @@ -1099,7 +1200,7 @@ function ArtifactPreviewFallback({ target="_blank" rel="noreferrer" > - 在新标签页打开 + 点击下载
); @@ -1239,7 +1340,11 @@ function buildArtifactViewerSrcDoc({ return `
`; } if (kind === "pdf") { - return ``; + return `
+

${safeName}

+

PDF preview is temporarily disabled. Please download the file to view it.

+ Open in new tab +
`; } if (kind === "html") { return ``; diff --git a/frontend/tests/e2e/artifact-generation-preview-download.spec.ts b/frontend/tests/e2e/artifact-generation-preview-download.spec.ts index 949b10ad..0836a669 100644 --- a/frontend/tests/e2e/artifact-generation-preview-download.spec.ts +++ b/frontend/tests/e2e/artifact-generation-preview-download.spec.ts @@ -165,7 +165,7 @@ async function waitAfterCardClick(page: Page, kind: string): Promise { return; } if (kind === "pptx") { - await expect(page.locator(".pptx-preview-wrap").first()).toBeVisible({ + await expect(page.getByText("请下载ppt文件以获得最佳效果").first()).toBeVisible({ timeout: 60_000, }); return;