import React, { useEffect, useMemo, useState } from 'react'; import { Box, CircularProgress, IconButton } from '@mui/material'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; import { useAuth } from '../../../contexts/AuthContext'; type Artefact = { id: string; type: string; rel_path: string; created_at: string }; type DoclingJson = Record & { pages?: Array<{ image_base64?: string; image?: { uri?: string; image_base64?: string; mimetype?: string }; width?: number; height?: number; }> | Record; page_images?: Array<{ uri?: string; image_base64?: string }>; images?: Array<{ uri?: string; image_base64?: string }>; frontpage?: { image_base64?: string }; cover?: { image_base64?: string }; }; type PageImagesManifest = { version: number; file_id: string; page_count: number; bucket?: string; base_dir?: string; page_images: Array<{ page: number; full_image_path: string; thumbnail_path: string; full_dimensions?: { width: number; height: number }; thumbnail_dimensions?: { width: number; height: number }; }> }; export const CCDoclingViewer: React.FC<{ fileId: string; currentPage?: number; onPageChange?: (page: number) => void; onExtractedText?: (text: string) => void; onTotalPagesChange?: (total: number) => void; hideToolbar?: boolean; sectionRange?: { start: number; end: number }; }> = ({ fileId, currentPage, onPageChange, onExtractedText, onTotalPagesChange, hideToolbar, sectionRange }) => { const { accessToken } = useAuth(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [images, setImages] = useState>([]); const [manifest, setManifest] = useState(null); const [pageLocal, setPageLocal] = useState(1); const page = typeof currentPage === 'number' ? currentPage : pageLocal; const norm = (s: unknown): string => String(s ?? '') .replace(/\r\n/g, '\n') .replace(/\t/g, ' ') .replace(/[ \f\v]{2,}/g, ' ') .replace(/\n{3,}/g, '\n\n') .trim(); const asRecord = (v: unknown): Record => (v && typeof v === 'object') ? (v as Record) : {}; const asArray = (v: unknown): unknown[] => Array.isArray(v) ? v : []; const getText = (n: unknown): string => { const node = asRecord(n); const cands = [ node['text'], node['orig'], node['content'], node['value'], node['md'], node['markdown'], node['plain_text'], node['caption'], node['title'] ]; const val = cands.find((v): v is string => typeof v === 'string' && v.trim().length > 0); return norm(val ?? ''); }; const collectSimpleText = (doc: unknown): string => { const docRec = asRecord(doc); const d = asRecord(asRecord(docRec['document'])['json_content'] ?? docRec['json_content'] ?? docRec); const parts: string[] = []; for (const t of asArray(d['texts'])) { const txt = getText(t); if (txt) parts.push(txt); } for (const li of asArray(d['lists'])) { const items = asArray(asRecord(li)['items']).map(getText).filter(Boolean) as string[]; if (items.length) parts.push(norm(items.join('\n'))); } for (const tbl of asArray(d['tables'])) { const data = asRecord(asRecord(tbl)['data']); const grid = asArray(data['grid']); if (grid.length) { const rows = grid.map((row) => `| ${asArray(row).map((c) => getText(c)).join(' | ')} |`); if (rows.length >= 2) { const firstLen = asArray(grid[0]).length; rows.splice(1, 0, `| ${Array(firstLen).fill('---').join(' | ')} |`); } if (rows.length) parts.push(norm(rows.join('\n'))); } else { const rowsArr = asArray(data['rows']); if (rowsArr.length) { const rows: string[] = []; for (const r of rowsArr) { const rRec = asRecord(r); const cells = (asArray(rRec['cells']).length ? asArray(rRec['cells']) : asArray(r)).map((c) => getText(c)); rows.push(`| ${cells.join(' | ')} |`); } if (rows.length) parts.push(norm(rows.join('\n'))); } } } return norm(parts.join('\n\n')); }; const extractImages = (rawDoc: unknown): Array<{ src: string; width?: number; height?: number }> => { const docRec = asRecord(rawDoc); const d = asRecord(asRecord(docRec['document'])['json_content'] ?? docRec['json_content'] ?? docRec); const out: Array<{ src: string; width?: number; height?: number }> = []; const pushUri = (uri?: string) => { if (!uri) return; if (uri.startsWith('data:')) out.push({ src: uri }); else if (/^[A-Za-z0-9+/=]+$/.test(uri)) out.push({ src: `data:image/png;base64,${uri}` }); }; // Case 1: pages is an array with image_base64 or image.uri const pagesVal = d['pages']; if (Array.isArray(pagesVal)) { for (const p of pagesVal) { const pRec = asRecord(p); const image = asRecord(pRec['image']); const b64 = pRec['image_base64'] as string | undefined; const b64img = image['image_base64'] as string | undefined; const uri = image['uri'] as string | undefined; if (b64) out.push({ src: `data:image/png;base64,${b64}` }); else if (b64img) out.push({ src: `data:image/png;base64,${b64img}` }); else if (uri) pushUri(uri); } } // Case 2: pages is an object keyed by page number, each with image.uri if (!out.length && pagesVal && typeof pagesVal === 'object' && !Array.isArray(pagesVal)) { const pagesRec = asRecord(pagesVal); const keys = Object.keys(pagesRec).sort((a, b) => Number(a) - Number(b)); for (const k of keys) { const pRec = asRecord(pagesRec[k]); const img = asRecord(pRec['image']); const uri = img['uri'] as string | undefined; const b64 = pRec['image_base64'] as string | undefined; if (uri) pushUri(uri); else if (b64) out.push({ src: `data:image/png;base64,${b64}` }); } } // Case 3: page_images or images arrays with data URIs if (!out.length && Array.isArray(d['page_images'])) { for (const im of d['page_images'] as Array>) pushUri((im['uri'] as string | undefined) || (im['image_base64'] as string | undefined)); } if (!out.length && Array.isArray(d['images'])) { for (const im of d['images'] as Array>) pushUri((im['uri'] as string | undefined) || (im['image_base64'] as string | undefined)); } // Fallback: frontpage/cover only const front = asRecord(d['frontpage']); const cover = asRecord(d['cover']); const frontB64 = front['image_base64'] as string | undefined; const coverB64 = cover['image_base64'] as string | undefined; if (!out.length && (frontB64 || coverB64)) { const src = frontB64 || coverB64; if (src) out.push({ src: `data:image/png;base64,${src}` }); } return out; }; useEffect(() => { const run = async () => { if (!fileId) return; setLoading(true); setError(null); try { // Try page-images manifest first const API_BASE = import.meta.env.VITE_API_BASE || '/api'; try { const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, { headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (mRes.ok) { const m: PageImagesManifest = await mRes.json(); setManifest(m); setImages([]); // we will render via manifest in viewer if (!currentPage) setPageLocal(1); return; // skip legacy docling path } } catch (e) { // ignore and fallback to legacy } // Legacy: Load artefacts for file to find docling JSON artefacts const artefactsRes = await fetch(`${import.meta.env.VITE_API_BASE || '/api'}/database/files/${encodeURIComponent(fileId)}/artefacts`, { headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (!artefactsRes.ok) throw new Error(await artefactsRes.text()); const artefacts: Artefact[] = await artefactsRes.json(); // Prefer full-file no-OCR artefact for complete page images const noocr = artefacts.find(a => a.type === 'docling_noocr_json'); const frontmatter = artefacts.find(a => a.type === 'docling_frontmatter_json'); const target = noocr || frontmatter; if (!target) { setError('No Docling artefacts found. Generate initial artefacts from the file menu.'); setImages([]); return; } // Download artefact JSON via backend (service-role) to avoid RLS issues const jsonRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/artefacts/${encodeURIComponent(target.id)}/json`, { headers: { 'Authorization': `Bearer ${accessToken || ''}` } }); if (!jsonRes.ok) throw new Error(await jsonRes.text()); const doc: DoclingJson = await jsonRes.json(); const imgs = extractImages(doc); setImages(imgs); if (onExtractedText) { const text = collectSimpleText(doc); onExtractedText(text); } } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed to load document'); setImages([]); } finally { setLoading(false); } }; run(); }, [fileId]); /* eslint-disable-line react-hooks/exhaustive-deps */ const API_BASE = useMemo(() => import.meta.env.VITE_API_BASE || '/api', []); const pageProxyUrl = useMemo(() => { if (!manifest) return undefined; const idx = Math.max(0, Math.min((manifest.page_count || 1) - 1, (page || 1) - 1)); const pg = manifest.page_images[idx]; if (!pg) return undefined; const bucket = manifest.bucket || ''; const path = pg.full_image_path; return `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(bucket)}&path=${encodeURIComponent(path)}`; }, [manifest, page, API_BASE]); const [pageObjectUrl, setPageObjectUrl] = useState(undefined); const [cacheUrls] = useState>(() => new Map()); useEffect(() => { let revoked: string | null = null; const load = async () => { if (!pageProxyUrl || !manifest) { setPageObjectUrl(undefined); return; } // Cache by page number to avoid repeated fetches const key = page; const cached = cacheUrls.get(key); if (cached) { setPageObjectUrl(cached); return; } const token = accessToken || ''; let resp = await fetch(pageProxyUrl, { headers: { Authorization: `Bearer ${token}` } }); if (!resp.ok && manifest) { // Fallback to thumbnail if the full image is not accessible yet const idx = Math.max(0, Math.min((manifest.page_count || 1) - 1, (page || 1) - 1)); const pg = manifest.page_images[idx]; if (pg) { const thumbUrl = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pg.thumbnail_path)}`; resp = await fetch(thumbUrl, { headers: { Authorization: `Bearer ${token}` } }); } } if (!resp.ok) { setError(`Failed to load page ${page}: ${resp.status}`); setPageObjectUrl(undefined); return; } const blob = await resp.blob(); const url = URL.createObjectURL(blob); cacheUrls.set(key, url); setPageObjectUrl(url); revoked = url; }; load(); return () => { // Do not revoke cached urls immediately; only revoke if it's a temp assignment // We keep the cache for navigation performance. if (revoked && ![...cacheUrls.values()].includes(revoked)) { URL.revokeObjectURL(revoked); } }; }, [pageProxyUrl, manifest, page, cacheUrls]); const totalPages = manifest?.page_count || images.length || 1; // Inform parent about total pages when it changes useEffect(() => { if (onTotalPagesChange) onTotalPagesChange(totalPages); // eslint-disable-next-line react-hooks/exhaustive-deps }, [totalPages]); const handlePageChange = (p: number) => { const clamped = Math.max(1, Math.min(totalPages, p)); if (onPageChange) onPageChange(clamped); else setPageLocal(clamped); }; const content = useMemo(() => { if (loading) return ; if (error) return {error}; // New single-page view using manifest if (manifest) { // Multi-page section view const start = sectionRange?.start ?? page; const end = sectionRange?.end ?? page; const pages: number[] = []; for (let p = start; p <= Math.min(end, totalPages); p++) pages.push(p); return ( {!hideToolbar && ( handlePageChange(start - 1)} disabled={start <= 1}> Section {start}–{end} handlePageChange(end + 1)} disabled={end >= totalPages}> )} {pages.map((p) => { const idx = Math.max(0, Math.min((manifest.page_count || 1) - 1, p - 1)); const pg = manifest.page_images[idx]; if (!pg) return null; const url = `${API_BASE}/database/files/proxy?bucket=${encodeURIComponent(manifest.bucket || '')}&path=${encodeURIComponent(pg.full_image_path)}`; return ( ); })} ); } // Fallback legacy rendering if (!images.length) return No page images available.; return ( {images.map((img, i) => ( {`Page ))} ); }, [loading, error, images, manifest, pageObjectUrl, page, totalPages]); return ( {content} ); }; export default CCDoclingViewer; const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => { const { accessToken } = useAuth(); const [blobUrl, setBlobUrl] = useState(undefined); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let revoked: string | null = null; const load = async () => { try { const token = accessToken || ''; const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const blob = await resp.blob(); const obj = URL.createObjectURL(blob); setBlobUrl(obj); revoked = obj; } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed to load page'); } finally { setLoading(false); } }; load(); return () => { if (revoked) URL.revokeObjectURL(revoked); }; }, [url]); if (loading) return ; if (error || !blobUrl) return {error || 'No image'}; return ( {alt} ); };