From c239fbb296a4cb24f667c606565d8feeb01efad5 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sun, 31 May 2026 21:52:32 -0700 Subject: [PATCH] Fix PDF thumbnails (ghostscript), 3MF viewer (official loader), PDF viewer (blob fetch) --- backend/Dockerfile | 1 + backend/workers/thumbnails.go | 37 ++++++- frontend/next.config.js | 1 + frontend/src/components/PdfViewer.tsx | 123 ++++++++++++++++++--- frontend/src/components/ThreeMfPreview.tsx | 107 +++++++++++------- 5 files changed, 218 insertions(+), 51 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 42faeae..70e99b8 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -21,6 +21,7 @@ RUN apk add --no-cache \ ffmpeg \ exiftool \ sqlite \ + ghostscript \ ca-certificates \ mailcap \ tzdata diff --git a/backend/workers/thumbnails.go b/backend/workers/thumbnails.go index 7544f0d..31f8e11 100644 --- a/backend/workers/thumbnails.go +++ b/backend/workers/thumbnails.go @@ -148,7 +148,9 @@ func (m *ThumbnailManager) worker(id int) { err = generateVideoThumbnail(fullSourcePath, destPath) } else if strings.HasPrefix(job.MimeType, "audio/") { err = generateAudioThumbnail(fullSourcePath, destPath) - } else if strings.HasSuffix(job.FilePath, ".docx") || strings.HasSuffix(job.FilePath, ".pptx") || strings.HasSuffix(job.FilePath, ".xlsx") || strings.HasSuffix(strings.ToLower(job.FilePath), ".pdf") { + } else if strings.HasSuffix(strings.ToLower(job.FilePath), ".pdf") { + err = generatePdfThumbnail(fullSourcePath, destPath) + } else if strings.HasSuffix(job.FilePath, ".docx") || strings.HasSuffix(job.FilePath, ".pptx") || strings.HasSuffix(job.FilePath, ".xlsx") { err = generateOfficeThumbnail(job.FilePath, destPath, m.Config) } else { continue @@ -211,6 +213,39 @@ func generateAudioThumbnail(src, dest string) error { return nil } +func generatePdfThumbnail(src, dest string) error { + // Use Ghostscript to render the first page of the PDF as a JPEG + tmpFile := dest + ".tmp.jpg" + cmd := exec.Command("gs", + "-dNOPAUSE", "-dBATCH", "-dSAFER", "-dQUIET", + "-sDEVICE=jpeg", + "-dFirstPage=1", "-dLastPage=1", + "-r72", // 72 DPI is enough for a thumbnail + "-dJPEGQ=85", + fmt.Sprintf("-sOutputFile=%s", tmpFile), + src, + ) + + timer := time.AfterFunc(30*time.Second, func() { + if cmd.Process != nil { + cmd.Process.Kill() + } + }) + + err := cmd.Run() + timer.Stop() + + if err != nil { + os.Remove(tmpFile) + return fmt.Errorf("ghostscript failed: %w", err) + } + + // Resize the rendered page to standard thumbnail size + err = generateImageThumbnail(tmpFile, dest) + os.Remove(tmpFile) + return err +} + func generateOfficeThumbnail(filePath, destPath string, cfg *config.Config) error { // Generate a download token for the Conversion API to fetch the file from our backend token, err := middleware.GenerateDownloadToken(0, "system_worker", cfg.JWTSecret, filePath) diff --git a/frontend/next.config.js b/frontend/next.config.js index 153f07c..81f9fd3 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -5,6 +5,7 @@ const nextConfig = { serverActions: { bodySizeLimit: '100gb', }, + proxyTimeout: 300000, // 5 minutes for large file proxying }, async rewrites() { return [ diff --git a/frontend/src/components/PdfViewer.tsx b/frontend/src/components/PdfViewer.tsx index e9f4203..841677d 100644 --- a/frontend/src/components/PdfViewer.tsx +++ b/frontend/src/components/PdfViewer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { Document, Page, pdfjs } from 'react-pdf'; import { ZoomIn, ZoomOut, Loader2 } from 'lucide-react'; import { useInView } from 'react-intersection-observer'; @@ -11,24 +11,44 @@ if (typeof window !== 'undefined') { pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; } -function VirtualPage({ pageNumber, scale }: { pageNumber: number; scale: number }) { +function LazyPage({ pageNumber, scale, containerWidth }: { pageNumber: number; scale: number; containerWidth: number }) { const { ref, inView } = useInView({ triggerOnce: false, - rootMargin: '200% 0px', // load pages 2 screens ahead/behind + rootMargin: '150% 0px', }); + // Estimate the page height based on typical A4 ratio (1:1.414) + const estimatedWidth = Math.min(containerWidth * 0.9, 900) * scale; + const estimatedHeight = estimatedWidth * 1.414; + return ( -
+
{inView ? ( + +
+ } /> ) : ( -
+
)}
); @@ -36,24 +56,96 @@ function VirtualPage({ pageNumber, scale }: { pageNumber: number; scale: number export default function PdfViewer({ url }: { url: string }) { const [numPages, setNumPages] = useState(); - const [scale, setScale] = useState(1.2); + const [scale, setScale] = useState(1.0); + const [blobUrl, setBlobUrl] = useState(null); + const [fetchError, setFetchError] = useState(null); + const [fetchingPdf, setFetchingPdf] = useState(true); + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(800); + + // Measure container width for responsive page sizing + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver(([entry]) => { + setContainerWidth(entry.contentRect.width); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Fetch the PDF as a blob to avoid Next.js proxy streaming issues with large files + useEffect(() => { + let cancelled = false; + setFetchingPdf(true); + setFetchError(null); + + fetch(url) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.blob(); + }) + .then((blob) => { + if (cancelled) return; + const objectUrl = URL.createObjectURL(blob); + setBlobUrl(objectUrl); + }) + .catch((err) => { + if (cancelled) return; + setFetchError(`Failed to load PDF: ${err.message}`); + }) + .finally(() => { + if (!cancelled) setFetchingPdf(false); + }); + + return () => { + cancelled = true; + if (blobUrl) URL.revokeObjectURL(blobUrl); + }; + }, [url]); // eslint-disable-line react-hooks/exhaustive-deps function onDocumentLoadSuccess({ numPages }: { numPages: number }): void { setNumPages(numPages); } + if (fetchingPdf) { + return ( +
+ + Loading PDF... +
+ ); + } + + if (fetchError) { + return ( +
+ {fetchError} + + Download Instead + +
+ ); + } + return (
{/* PDF Toolbar */}
- {numPages ? `${numPages} Pages` : 'Loading...'} + {numPages ? `${numPages} page${numPages !== 1 ? 's' : ''}` : 'Loading...'}
{/* PDF Content */} -
+
console.error('Failed to load PDF:', error)} loading={ @@ -85,8 +177,13 @@ export default function PdfViewer({ url }: { url: string }) { } className="flex flex-col items-center gap-2 w-full" > - {numPages && Array.from(new Array(numPages), (el, index) => ( - + {numPages && Array.from(new Array(numPages), (_, index) => ( + ))}
diff --git a/frontend/src/components/ThreeMfPreview.tsx b/frontend/src/components/ThreeMfPreview.tsx index f10834c..414a629 100644 --- a/frontend/src/components/ThreeMfPreview.tsx +++ b/frontend/src/components/ThreeMfPreview.tsx @@ -1,7 +1,6 @@ -import React, { useState, useEffect } from 'react'; -import { Canvas } from '@react-three/fiber'; -import { OrbitControls, Center } from '@react-three/drei'; -import { ThreeMFLoader } from 'three-stdlib'; +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import { Canvas, useThree } from '@react-three/fiber'; +import { OrbitControls } from '@react-three/drei'; import { Loader2 } from 'lucide-react'; import * as THREE from 'three'; @@ -11,29 +10,64 @@ interface ThreeMfPreviewProps { function Model({ url, onLoad, onError }: { url: string; onLoad: () => void; onError: (e: any) => void }) { const [model, setModel] = useState(null); + const { camera } = useThree(); useEffect(() => { - const loader = new ThreeMFLoader(); - loader.load( - url, - (group) => { - // Adjust materials for shadow casting - group.traverse((child) => { - if ((child as THREE.Mesh).isMesh) { - const mesh = child as THREE.Mesh; - mesh.castShadow = true; - mesh.receiveShadow = true; + let cancelled = false; + + // Dynamically import the official Three.js 3MFLoader + import('three/examples/jsm/loaders/3MFLoader.js').then(({ ThreeMFLoader }) => { + if (cancelled) return; + const loader = new ThreeMFLoader(); + + loader.load( + url, + (group: THREE.Group) => { + if (cancelled) return; + + // Ensure all meshes have materials and shadows + group.traverse((child) => { + if ((child as THREE.Mesh).isMesh) { + const mesh = child as THREE.Mesh; + mesh.castShadow = true; + mesh.receiveShadow = true; + // Ensure material exists + if (!mesh.material) { + mesh.material = new THREE.MeshStandardMaterial({ color: 0x888888 }); + } + } + }); + + // Center and fit model to camera + const box = new THREE.Box3().setFromObject(group); + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + group.position.sub(center); + + // Adjust camera to fit + const maxDim = Math.max(size.x, size.y, size.z); + if (maxDim > 0) { + const fov = (camera as THREE.PerspectiveCamera).fov * (Math.PI / 180); + const dist = (maxDim / 2) / Math.tan(fov / 2) * 1.5; + camera.position.set(dist * 0.7, dist * 0.5, dist); + camera.lookAt(0, 0, 0); } - }); - setModel(group); - onLoad(); - }, - undefined, - (err) => { - onError(err); - } - ); - }, [url, onLoad, onError]); + + setModel(group); + onLoad(); + }, + undefined, + (err: any) => { + if (cancelled) return; + onError(err instanceof Error ? err : new Error(String(err))); + } + ); + }).catch((err) => { + if (!cancelled) onError(err); + }); + + return () => { cancelled = true; }; + }, [url]); // eslint-disable-line react-hooks/exhaustive-deps if (!model) return null; return ; @@ -64,21 +98,20 @@ export default function ThreeMfPreview({ url }: ThreeMfPreviewProps) {
- + - + + -
- setLoading(false)} - onError={(err) => { - console.error('3MF Load Error:', err); - setError(err); - setLoading(false); - }} - /> -
+ setLoading(false)} + onError={(err) => { + console.error('3MF Load Error:', err); + setError(err); + setLoading(false); + }} + />