feat: implemented export and wasm rendering

This commit is contained in:
Furqan-14
2026-06-09 10:39:45 +05:30
parent 5408540847
commit 83e18fff6f
26 changed files with 787 additions and 181 deletions
File diff suppressed because one or more lines are too long
Binary file not shown.
+26
View File
@@ -7,6 +7,7 @@ import type { Annotation } from './viewer/AnnotationLayer';
import { gatewayService } from './lib/gatewayService';
import type { DocumentInfo, SearchResult } from './lib/gatewayService';
import { wasmLoader } from './lib/wasmLoader';
import { WasmInspector } from './components/WasmInspector';
import './App.css';
function App() {
@@ -25,6 +26,7 @@ function App() {
const [backendHealthy, setBackendHealthy] = useState<boolean | null>(null);
const [sidebarTab, setSidebarTab] = useState<'documents' | 'annotations' | 'outline'>('documents');
const [isLoading, setIsLoading] = useState<boolean>(true);
const [wasmInspectorOpen, setWasmInspectorOpen] = useState<boolean>(false);
// Search State
const [searchQuery, setSearchQuery] = useState('');
@@ -328,6 +330,19 @@ function App() {
}
};
const handleExport = async () => {
if (!selectedDocId || !activeDoc) return;
try {
setIsLoading(true);
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
} catch (err) {
console.error('Failed to export document:', err);
alert('Failed to export document. Make sure the gateway is connected.');
} finally {
setIsLoading(false);
}
};
return (
<div className="w-screen h-screen flex flex-col overflow-hidden bg-slate-950 font-sans text-slate-100 antialiased">
{/* Top Navigation / Toolbar */}
@@ -347,6 +362,9 @@ function App() {
searchCurrentMatch={searchCurrentMatch}
onSearchNext={handleSearchNext}
onSearchPrev={handleSearchPrev}
onExport={handleExport}
onWasmInspectToggle={() => setWasmInspectorOpen(!wasmInspectorOpen)}
wasmInspectorOpen={wasmInspectorOpen}
/>
<div className="flex-1 w-full flex overflow-hidden">
@@ -401,6 +419,14 @@ function App() {
<p className="text-sm font-bold">No active document. Please upload a PDF file.</p>
</div>
)}
{wasmInspectorOpen && activeDoc && (
<WasmInspector
documentId={activeDoc.id}
currentPage={currentPage}
onClose={() => setWasmInspectorOpen(false)}
/>
)}
</div>
</div>
);
+30
View File
@@ -19,6 +19,9 @@ interface ToolbarProps {
searchCurrentMatch: number;
onSearchNext: () => void;
onSearchPrev: () => void;
onExport?: () => void;
onWasmInspectToggle?: () => void;
wasmInspectorOpen?: boolean;
}
export const Toolbar: React.FC<ToolbarProps> = ({
@@ -37,6 +40,9 @@ export const Toolbar: React.FC<ToolbarProps> = ({
searchCurrentMatch,
onSearchNext,
onSearchPrev,
onExport,
onWasmInspectToggle,
wasmInspectorOpen = false,
}) => {
const handleZoomPercentSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
@@ -194,6 +200,30 @@ export const Toolbar: React.FC<ToolbarProps> = ({
onPrev={onSearchPrev}
/>
{onWasmInspectToggle && (
<button
onClick={onWasmInspectToggle}
className={`wasm-badge cursor-pointer hover:bg-indigo-500/25 hover:border-indigo-400/40 transition-all ${wasmInspectorOpen ? 'border-indigo-400 bg-indigo-500/25 text-indigo-200' : ''}`}
title="Inspect client-side WebAssembly execution"
>
<span className="wasm-dot" />
WASM Inspect
</button>
)}
{onExport && (
<button
onClick={onExport}
className="export-btn"
title="Export clean PDF (Stripping incremental history)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Export PDF
</button>
)}
<label className="upload-btn">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
+252
View File
@@ -0,0 +1,252 @@
import React, { useEffect, useRef, useState } from 'react';
import { wasmLoader } from '../lib/wasmLoader';
import { gatewayService } from '../lib/gatewayService';
interface WasmInspectorProps {
documentId: string;
currentPage: number;
onClose: () => void;
}
export const WasmInspector: React.FC<WasmInspectorProps> = ({
documentId,
currentPage,
onClose,
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [status, setStatus] = useState<string>('Initializing...');
const [logs, setLogs] = useState<string[]>([]);
const [engineInfo, setEngineInfo] = useState<string>('');
const [textJson, setTextJson] = useState<string>('');
const [isCompiling, setIsCompiling] = useState<boolean>(true);
const [stats, setStats] = useState<{
loadTimeMs: number;
renderTimeMs: number;
textTimeMs: number;
docHandle: number;
fileSize: number;
}>({ loadTimeMs: 0, renderTimeMs: 0, textTimeMs: 0, docHandle: 0, fileSize: 0 });
const addLog = (msg: string) => {
setLogs((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
};
useEffect(() => {
let active = true;
let docHandle = 0;
let engine: any = null;
const runWasmPipeline = async () => {
try {
setIsCompiling(true);
setStatus('Loading WASM module...');
addLog('Initializing Emscripten compiler context in browser...');
engine = await wasmLoader.loadEngine();
if (!active) return;
const info = engine.engineBuildInfo();
setEngineInfo(info);
addLog(`C++ Core Loaded: ${info}`);
setStatus('Downloading PDF bytes from Gateway...');
addLog(`Fetching document ID: ${documentId} ...`);
const startTimeFetch = performance.now();
const buffer = await gatewayService.fetchDocumentBytes(documentId);
const fetchTime = performance.now() - startTimeFetch;
if (!active) return;
addLog(`Downloaded ${buffer.byteLength} bytes in ${fetchTime.toFixed(1)}ms`);
setStatus('Allocating heap and loading document...');
addLog('Calling loadDocument() on WASM engine...');
const startTimeLoad = performance.now();
docHandle = engine.loadDocument(buffer);
const loadTime = performance.now() - startTimeLoad;
if (!active) return;
if (docHandle <= 0) {
throw new Error('loadDocument failed to return a valid handle');
}
addLog(`Document loaded successfully. Allocated handle ID: ${docHandle} in ${loadTime.toFixed(1)}ms`);
// Update stats
setStats((prev) => ({
...prev,
docHandle,
fileSize: buffer.byteLength,
loadTimeMs: loadTime,
}));
// Render Page
setStatus('Rendering page client-side...');
addLog(`Calling renderPage(handle: ${docHandle}, pageIndex: ${currentPage}, scale: 1.0) ...`);
const startTimeRender = performance.now();
const scale = 1.0;
const imageData = engine.renderPage(docHandle, currentPage, scale);
const renderTime = performance.now() - startTimeRender;
if (!active) return;
addLog(`Page rendered to raw pixel RGBA buffer in ${renderTime.toFixed(1)}ms`);
// Draw onto Canvas
if (canvasRef.current) {
const canvas = canvasRef.current;
canvas.width = imageData.width;
canvas.height = imageData.height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.putImageData(imageData, 0, 0);
addLog(`Rendered pixel bytes displayed on HTML5 canvas (${imageData.width}x${imageData.height})`);
}
}
// Get Text JSON
setStatus('Extracting page text...');
addLog(`Calling getTextJson(handle: ${docHandle}, pageIndex: ${currentPage}) ...`);
const startTimeText = performance.now();
const textData = engine.getTextJson(docHandle, currentPage);
const textTime = performance.now() - startTimeText;
if (!active) return;
addLog(`Text query complete in ${textTime.toFixed(1)}ms`);
// Pretty print JSON
try {
const parsed = JSON.parse(textData);
setTextJson(JSON.stringify(parsed, null, 2));
} catch {
setTextJson(textData);
}
setStats((prev) => ({
...prev,
renderTimeMs: renderTime,
textTimeMs: textTime,
}));
setStatus('Complete');
setIsCompiling(false);
} catch (err: any) {
addLog(`ERROR: ${err.message}`);
setStatus('Pipeline Failed');
setIsCompiling(false);
}
};
runWasmPipeline();
return () => {
active = false;
if (docHandle > 0 && engine) {
try {
engine.freeDocument(docHandle);
console.log(`[WasmInspector] Freed doc handle ${docHandle}`);
} catch (e) {
console.error(e);
}
}
};
}, [documentId, currentPage]);
return (
<div className="flex flex-col w-[400px] border-l border-slate-800 bg-slate-900/90 backdrop-blur-md h-full text-slate-200 overflow-hidden shadow-2xl z-40 animate-in slide-in-from-right duration-300">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-800 bg-slate-950/40">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-indigo-500 animate-pulse shadow-[0_0_8px_#6366f1]" />
<h3 className="font-bold text-sm tracking-wider uppercase text-indigo-400">WASM Compiler Inspect</h3>
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-white p-1.5 rounded-lg hover:bg-slate-800 transition-colors"
title="Close Inspector"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Status Card */}
<div className="p-3.5 rounded-xl border border-slate-800 bg-slate-950/20">
<div className="text-xs text-slate-400 font-medium">Pipeline Status</div>
<div className="text-sm font-bold text-slate-100 flex items-center gap-2 mt-1">
{isCompiling && <span className="w-3.5 h-3.5 border-2 border-indigo-400 border-t-transparent rounded-full animate-spin" />}
{status}
</div>
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-2 gap-2.5">
<div className="p-3 rounded-lg border border-slate-800 bg-slate-950/10">
<div className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider">Doc Handle ID</div>
<div className="text-sm font-bold mt-0.5 text-indigo-300">{stats.docHandle || 'N/A'}</div>
</div>
<div className="p-3 rounded-lg border border-slate-800 bg-slate-950/10">
<div className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider">File Size</div>
<div className="text-sm font-bold mt-0.5 text-slate-200">
{stats.fileSize ? `${(stats.fileSize / 1024).toFixed(1)} KB` : '0 KB'}
</div>
</div>
<div className="p-3 rounded-lg border border-slate-800 bg-slate-950/10">
<div className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider">WASM Load</div>
<div className="text-sm font-bold mt-0.5 text-emerald-400">
{stats.loadTimeMs ? `${stats.loadTimeMs.toFixed(1)}ms` : '0ms'}
</div>
</div>
<div className="p-3 rounded-lg border border-slate-800 bg-slate-950/10">
<div className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider">Render Time</div>
<div className="text-sm font-bold mt-0.5 text-emerald-400">
{stats.renderTimeMs ? `${stats.renderTimeMs.toFixed(1)}ms` : '0ms'}
</div>
</div>
</div>
{/* Client-side Canvas Preview */}
<div className="space-y-1.5">
<div className="text-[11px] font-bold tracking-wider uppercase text-slate-400">Client Canvas Output</div>
<div className="border border-slate-800 rounded-xl bg-slate-950 flex items-center justify-center p-4 overflow-hidden relative min-h-[160px]">
<canvas ref={canvasRef} className="max-w-full max-h-[220px] rounded shadow-lg object-contain bg-slate-900 border border-slate-800/40" />
{!stats.renderTimeMs && (
<div className="absolute text-slate-500 text-xs font-bold uppercase tracking-widest">Awaiting Render...</div>
)}
</div>
</div>
{/* Text Bounds JSON Output */}
<div className="flex-1 flex flex-col space-y-1.5 min-h-[220px]">
<div className="text-[11px] font-bold tracking-wider uppercase text-slate-400">extractTextWithBounds() JSON</div>
<div className="flex-1 min-h-[150px] max-h-[300px] border border-slate-800 rounded-xl bg-slate-950 p-3 overflow-y-auto font-mono text-xs text-indigo-300">
{textJson ? (
<pre className="whitespace-pre-wrap">{textJson}</pre>
) : (
<div className="text-slate-650 italic text-center py-8">No text extracted yet</div>
)}
</div>
</div>
{/* Console Logs */}
<div className="space-y-1.5">
<div className="text-[11px] font-bold tracking-wider uppercase text-slate-400">Execution Console Logs</div>
<div className="border border-slate-800 rounded-xl bg-slate-950 p-3 max-h-[200px] overflow-y-auto font-mono text-[10px] space-y-1 text-slate-400">
{logs.map((log, i) => (
<div key={i} className={log.includes('ERROR') ? 'text-rose-400' : log.includes('Success') || log.includes('successfully') ? 'text-emerald-400' : ''}>
{log}
</div>
))}
</div>
</div>
</div>
{/* Footer / Engine Tag */}
<div className="p-3 border-t border-slate-800 bg-slate-950/60 text-[10px] text-center text-slate-500 font-bold uppercase tracking-widest">
{engineInfo || 'WASM Engine Offline'}
</div>
</div>
);
};
+20
View File
@@ -364,6 +364,26 @@ body {
transform: translateY(-1px);
}
.export-btn {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.05);
color: white;
font-weight: 600;
font-size: 12px;
padding: 8px 16px;
border-radius: 10px;
cursor: pointer;
border: 1px solid var(--border-main);
transition: transform 0.2s, background-color 0.2s;
}
.export-btn:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateY(-1px);
}
.hidden-file-input {
display: none;
}
+20 -7
View File
@@ -1,10 +1,3 @@
/**
* Gateway API Service Client
*
* Handles all network requests to the FastAPI gateway backend.
* Provides endpoints for document CRUD, rendering, metadata retrieval, and edits.
*/
export interface PageInfo {
index: number;
width: number;
@@ -411,6 +404,26 @@ class GatewayService {
`;
return `data:image/svg+xml;utf8,${encodeURIComponent(svg.trim())}`;
}
async exportDocument(documentId: string, filename: string): Promise<void> {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/export`);
if (!response.ok) throw new Error(`Export failed: ${response.statusText}`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
async fetchDocumentBytes(documentId: string): Promise<ArrayBuffer> {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/export`);
if (!response.ok) throw new Error(`Failed to fetch document bytes: ${response.statusText}`);
return response.arrayBuffer();
}
}
export const gatewayService = new GatewayService();
+15
View File
@@ -12,6 +12,7 @@ export interface WasmEngineInstance {
freeDocument: (handle: number) => void;
engineBuildInfo: () => string;
engineHasSkia: () => boolean;
getTextJson: (handle: number, page: number) => string;
}
class WasmLoader {
@@ -85,6 +86,9 @@ class WasmLoader {
},
engineHasSkia: () => {
return Module.ccall('engineHasSkia', 'number', [], []) !== 0;
},
getTextJson: (handle: number, page: number) => {
return Module.ccall('getPageTextJson', 'string', ['number', 'number'], [handle, page]);
}
};
@@ -131,6 +135,17 @@ class WasmLoader {
},
engineHasSkia: () => {
return true;
},
getTextJson: (handle: number, page: number) => {
void handle;
return JSON.stringify([
{ text: "P", x: 60.0, y: 80.0, w: 6.0, h: 8.0, fontSize: 12.0 },
{ text: "a", x: 66.0, y: 80.0, w: 6.0, h: 8.0, fontSize: 12.0 },
{ text: "g", x: 72.0, y: 80.0, w: 6.0, h: 8.0, fontSize: 12.0 },
{ text: "e", x: 78.0, y: 80.0, w: 6.0, h: 8.0, fontSize: 12.0 },
{ text: " ", x: 84.0, y: 80.0, w: 6.0, h: 8.0, fontSize: 12.0 },
{ text: String(page + 1), x: 90.0, y: 80.0, w: 6.0, h: 8.0, fontSize: 12.0 }
]);
}
};
}
+6 -2
View File
@@ -34,7 +34,9 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
const handlePointerDown = (e: React.PointerEvent) => {
if (activeTool !== 'draw') return;
setIsDrawing(true);
e.target.setPointerCapture?.(e.pointerId);
if (e.target instanceof Element) {
e.target.setPointerCapture(e.pointerId);
}
setCurrentPath([getCoordinates(e)]);
};
@@ -46,7 +48,9 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
const handlePointerUp = (e: React.PointerEvent) => {
if (!isDrawing || activeTool !== 'draw') return;
setIsDrawing(false);
e.target.releasePointerCapture?.(e.pointerId);
if (e.target instanceof Element) {
e.target.releasePointerCapture(e.pointerId);
}
if (currentPath.length > 1) {
// Calculate bounding box
+8 -7
View File
@@ -59,11 +59,13 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
const containerRef = useRef<HTMLDivElement | null>(null);
const [scrollPosition, setScrollPosition] = useState({ scrollLeft: 0, scrollTop: 0 });
const [renderedPages, setRenderedPages] = useState<string[]>([]);
const [verifiedPages, setVerifiedPages] = useState<Record<number, boolean>>({});
const [containerHeight, setContainerHeight] = useState(800);
// Reset cached page renders when switching documents
useEffect(() => {
setRenderedPages([]);
setVerifiedPages({});
}, [documentId]);
// Use provided dimensions or fallback to Letter size
@@ -206,7 +208,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
useEffect(() => {
const verifyPageModel = async () => {
if (visiblePages.length > 0 && documentId && !renderedPages[visiblePages[0].index + '_verified']) {
if (visiblePages.length > 0 && documentId && !verifiedPages[visiblePages[0].index]) {
const pageIndex = visiblePages[0].index;
try {
const model = await gatewayService.getPageModel(documentId, pageIndex);
@@ -220,18 +222,17 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
});
});
});
setRenderedPages((prev) => {
const next = [...prev];
next[pageIndex + '_verified' as any] = 'true';
return next;
});
setVerifiedPages((prev) => ({
...prev,
[pageIndex]: true,
}));
} catch (e) {
// ignore or log
}
}
};
verifyPageModel();
}, [visiblePages, documentId]);
}, [visiblePages, documentId, verifiedPages]);
const handleTextSelection = (text: string, bbox: Rect) => {
if (activeTool === 'highlight') {