feat: add TIFF/TIF image preview support using UTIF (#194)

This commit is contained in:
liaozip
2026-08-05 00:08:28 +08:00
committed by GitHub
parent 0d965dcfe2
commit 28b2cd9794
5 changed files with 182 additions and 3 deletions
+14 -3
View File
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"
import { RiFullscreenExitLine, RiFullscreenLine } from "@remixicon/react"
import { PdfViewer } from "@/components/object/pdf-viewer"
import { ParquetViewer } from "@/components/object/parquet-viewer"
import { TiffViewer } from "@/components/object/tiff-viewer"
import Image from "next/image"
const SAFE_TEXT_MIMES = [
@@ -26,7 +27,7 @@ const SAFE_TEXT_EXTENSIONS = [".txt", ".json", ".jsonl", ".ndjson", ".xml", ".cs
const SAFE_IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".tif", ".tiff"]
const ALLOWED_SIZE = 1024 * 1024 * 2 // 2MB
type PreviewMode = "text" | "image" | "pdf" | "parquet" | "sandbox" | "download"
type PreviewMode = "text" | "image" | "pdf" | "parquet" | "sandbox" | "download" | "tiff"
const PARQUET_MIMES = ["application/vnd.apache.parquet", "application/x-parquet", "application/parquet"]
const PARQUET_EXTENSIONS = [".parquet", ".pq"]
@@ -86,6 +87,11 @@ function isParquetPreview(contentType: string, objectKey: string) {
return PARQUET_EXTENSIONS.some((ext) => keyLower.endsWith(ext))
}
function isTiffPreview(objectKey: string) {
const keyLower = objectKey.toLowerCase()
return keyLower.endsWith(".tif") || keyLower.endsWith(".tiff")
}
function getFullscreenElement(doc: FullscreenDocument): Element | null {
return doc.fullscreenElement ?? doc.webkitFullscreenElement ?? null
}
@@ -140,13 +146,16 @@ export function ObjectPreviewModal({ show, onShowChange, object }: ObjectPreview
const canRenderImage = hasPreviewUrl && isImagePreview(normalizedContentType, objectKey)
const canRenderPdf = hasPreviewUrl && isPdfPreview(normalizedContentType)
const canRenderParquet = hasPreviewUrl && isParquetPreview(normalizedContentType, objectKey)
const canRenderTiff = hasPreviewUrl && isTiffPreview(objectKey)
const previewMode: PreviewMode = canRenderParquet
? "parquet"
: canRenderPdf
? "pdf"
: getPreviewMode(hasPreviewUrl, canRenderText, canRenderImage)
: canRenderTiff
? "tiff"
: getPreviewMode(hasPreviewUrl, canRenderText, canRenderImage)
const isImageMode = previewMode === "image"
const isSelfScrollMode = isImageMode || previewMode === "parquet"
const isSelfScrollMode = isImageMode || previewMode === "parquet" || previewMode === "tiff"
const getFormattedContent = () => {
if (!isJson || !isFormatted) return textContent
@@ -374,6 +383,8 @@ export function ObjectPreviewModal({ show, onShowChange, object }: ObjectPreview
return <PdfViewer url={previewUrl} />
case "parquet":
return <ParquetViewer url={previewUrl} sizeBytes={objectSize} />
case "tiff":
return <TiffViewer url={previewUrl} objectKey={objectKey} />
case "download":
default:
return (
+136
View File
@@ -0,0 +1,136 @@
"use client"
import * as React from "react"
import { useTranslation } from "react-i18next"
import { Spinner } from "@/components/ui/spinner"
// UTIF types
interface UTIFModule {
decode: (buffer: ArrayBuffer) => Array<{ width: number; height: number; [key: string]: unknown }>
decodeImage: (buffer: ArrayBuffer, ifd: { width: number; height: number; [key: string]: unknown }) => void
toRGBA8: (ifd: { width: number; height: number; [key: string]: unknown }) => Uint8Array
}
interface TiffViewerProps {
url: string
objectKey: string
}
interface TiffImageData {
width: number
height: number
rgba: Uint8Array
}
/**
* TiffViewer — decode TIFF/TIF images client-side and render to Canvas.
* Uses utif for decoding with dynamic import to avoid bundling for non-TIFF usage.
* Supports compressed TIFF (LZW, Deflate, PackBits, JPEG).
*
* Two-phase rendering: phase 1 decodes the image and stores the result in state
* (setLoading(false) after decode); phase 2 renders to canvas once the canvas
* element is mounted to the DOM. This avoids a race condition where the canvas
* ref is null because the component is still displaying the loading spinner.
*/
export function TiffViewer({ url, objectKey }: TiffViewerProps) {
const { t } = useTranslation()
const canvasRef = React.useRef<HTMLCanvasElement | null>(null)
const [loading, setLoading] = React.useState(true)
const [error, setError] = React.useState("")
const [imageData, setImageData] = React.useState<TiffImageData | null>(null)
// Phase 1: fetch and decode the TIFF image
React.useEffect(() => {
let cancelled = false
const controller = new AbortController()
async function decodeTiff() {
setLoading(true)
setError("")
setImageData(null)
try {
const response = await fetch(url, { signal: controller.signal })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const buffer = await response.arrayBuffer()
if (cancelled) return
const UTIF: UTIFModule = await import("utif")
const ifds = UTIF.decode(buffer)
if (!ifds || ifds.length === 0) throw new Error("Invalid TIFF: no IFD found")
UTIF.decodeImage(buffer, ifds[0])
const rgba = UTIF.toRGBA8(ifds[0])
if (cancelled) return
setImageData({ width: ifds[0].width, height: ifds[0].height, rgba })
setLoading(false)
} catch (err: unknown) {
if (cancelled) return
const message =
err instanceof Error && err.name === "AbortError"
? ""
: err instanceof Error
? err.message
: String(err)
setError(message || t("Preview unavailable"))
setLoading(false)
}
}
decodeTiff()
return () => {
cancelled = true
controller.abort()
}
}, [url, t])
// Phase 2: render decoded image data to canvas (runs after canvas is in DOM)
React.useEffect(() => {
if (loading || !imageData) return
const canvas = canvasRef.current
if (!canvas) return
canvas.width = imageData.width
canvas.height = imageData.height
const ctx = canvas.getContext("2d")
if (!ctx) return
const imgData = ctx.createImageData(canvas.width, canvas.height)
imgData.data.set(imageData.rgba)
ctx.putImageData(imgData, 0, 0)
}, [loading, imageData])
if (loading) {
return (
<div className="flex flex-1 items-center justify-center">
<Spinner className="size-8 text-muted-foreground" />
</div>
)
}
if (error) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{error}
</div>
)
}
return (
<div className="flex flex-1 items-center justify-center overflow-auto">
<canvas
ref={canvasRef}
className="max-h-full max-w-full object-contain"
role="img"
aria-label={objectKey}
/>
</div>
)
}
+1
View File
@@ -51,6 +51,7 @@
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"ufo": "^1.6.4",
"utif": "^3.1.0",
"vaul": "^1.1.2"
},
"devDependencies": {
+15
View File
@@ -306,6 +306,9 @@ importers:
ufo:
specifier: ^1.6.4
version: 1.6.4
utif:
specifier: ^3.1.0
version: 3.1.0
vaul:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -3324,6 +3327,9 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -3944,6 +3950,9 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
utif@3.1.0:
resolution: {integrity: sha512-WEo4D/xOvFW53K5f5QTaTbbiORcm2/pCL9P6qmJnup+17eYfKaEhDeX9PeQkuyEoIxlbGklDuGl8xwuXYMrrXQ==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -7507,6 +7516,8 @@ snapshots:
dependencies:
p-limit: 3.1.0
pako@1.0.11: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -8247,6 +8258,10 @@ snapshots:
dependencies:
react: 19.2.7
utif@3.1.0:
dependencies:
pako: 1.0.11
util-deprecate@1.0.2: {}
validate-npm-package-name@7.0.2: {}
+16
View File
@@ -0,0 +1,16 @@
declare module "utif" {
interface UTIFIFD {
width: number
height: number
[key: string]: unknown
}
interface UTIFModule {
decode: (buffer: ArrayBuffer) => UTIFIFD[]
decodeImage: (buffer: ArrayBuffer, ifd: UTIFIFD) => void
toRGBA8: (ifd: UTIFIFD) => Uint8Array
}
const UTIF: UTIFModule
export = UTIF
}