This commit is contained in:
2025-11-14 14:47:26 +00:00
parent 69ecf2c7c1
commit 3b4876793e
104 changed files with 231517 additions and 1029 deletions
@@ -0,0 +1,403 @@
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 { supabase } from '../../../supabaseClient';
type Artefact = { id: string; type: string; rel_path: string; created_at: string };
type DoclingJson = Record<string, unknown> & {
pages?: Array<{
image_base64?: string;
image?: { uri?: string; image_base64?: string; mimetype?: string };
width?: number;
height?: number;
}> | Record<string, unknown>;
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 [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [images, setImages] = useState<Array<{ src: string; width?: number; height?: number }>>([]);
const [manifest, setManifest] = useState<PageImagesManifest | null>(null);
const [pageLocal, setPageLocal] = useState<number>(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<string, unknown> => (v && typeof v === 'object') ? (v as Record<string, unknown>) : {};
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<Record<string, unknown>>) 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<Record<string, unknown>>) 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 || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api');
try {
const mRes = await fetch(`${API_BASE}/database/files/${encodeURIComponent(fileId)}/page-images/manifest`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
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 || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/api')}/database/files/${encodeURIComponent(fileId)}/artefacts`, {
headers: { 'Authorization': `Bearer ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
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 ${(await supabase.auth.getSession()).data.session?.access_token || ''}` }
});
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 || (location.port.startsWith('517') ? 'http://127.0.0.1:8080' : '/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<string | undefined>(undefined);
const [cacheUrls] = useState<Map<number, string>>(() => 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 = (await supabase.auth.getSession()).data.session?.access_token || '';
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 <Box sx={{ p: 2 }}><CircularProgress size={20} /></Box>;
if (error) return <Box sx={{ p: 2, color: 'var(--color-text-2)' }}>{error}</Box>;
// 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 (
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
{!hideToolbar && (
<Box sx={{ p: 1, display: 'flex', alignItems: 'center', gap: 1, borderBottom: '1px solid var(--color-divider)' }}>
<IconButton size="small" onClick={() => handlePageChange(start - 1)} disabled={start <= 1}><ArrowBackIosNewIcon fontSize="inherit" /></IconButton>
<Box sx={{ fontSize: 12, color: 'var(--color-text-2)' }}>Section {start}{end}</Box>
<IconButton size="small" onClick={() => handlePageChange(end + 1)} disabled={end >= totalPages}><ArrowForwardIosIcon fontSize="inherit" /></IconButton>
</Box>
)}
<Box sx={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, p: 2 }}>
{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 (
<ImageByProxy key={p} url={url} alt={`Page ${p}`} />
);
})}
</Box>
</Box>
);
}
// Fallback legacy rendering
if (!images.length) return <Box sx={{ p: 2 }}>No page images available.</Box>;
return (
<Box sx={{ width: '100%', height: '100%', overflow: 'auto', p: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
{images.map((img, i) => (
<img key={i} src={img.src} alt={`Page ${i + 1}`} style={{ boxShadow: '0 2px 8px rgba(0,0,0,0.15)', maxWidth: '100%' }} />
))}
</Box>
</Box>
);
}, [loading, error, images, manifest, pageObjectUrl, page, totalPages]);
return (
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
{content}
</Box>
);
};
export default CCDoclingViewer;
const ImageByProxy: React.FC<{ url: string; alt: string }> = ({ url, alt }) => {
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let revoked: string | null = null;
const load = async () => {
try {
const token = (await supabase.auth.getSession()).data.session?.access_token || '';
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 <Box sx={{ p: 2 }}><CircularProgress size={18} /></Box>;
if (error || !blobUrl) return <Box sx={{ p: 2, color: 'var(--color-text-2)' }}>{error || 'No image'}</Box>;
return (
<img src={blobUrl} alt={alt} style={{ maxWidth: '100%', height: 'auto', display: 'block', boxShadow: '0 2px 8px rgba(0,0,0,0.15)' }} />
);
};