search fuunction done

This commit is contained in:
azeeee05
2026-06-08 11:22:15 +05:30
parent dc6e538c81
commit 31243722d3
12 changed files with 365 additions and 64 deletions
+30 -8
View File
@@ -5,7 +5,7 @@ import { PDFViewer } from './viewer/PDFViewer';
import type { PDFViewerRef } from './viewer/PDFViewer';
import type { Annotation } from './viewer/AnnotationLayer';
import { gatewayService } from './lib/gatewayService';
import type { DocumentInfo } from './lib/gatewayService';
import type { DocumentInfo, SearchResult } from './lib/gatewayService';
import { wasmLoader } from './lib/wasmLoader';
import './App.css';
@@ -29,16 +29,30 @@ function App() {
// Search State
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searchResultCount, setSearchResultCount] = useState(0);
const [searchCurrentMatch, setSearchCurrentMatch] = useState(0);
const handleSearch = (query: string) => {
const handleSearch = async (query: string) => {
setSearchQuery(query);
// Mock search results for Phase 0
if (query) {
setSearchResultCount(5);
if (!query || !selectedDocId) {
setSearchResults([]);
setSearchResultCount(0);
setSearchCurrentMatch(0);
} else {
return;
}
try {
const results = await gatewayService.searchDocument(selectedDocId, query);
setSearchResults(results);
setSearchResultCount(results.length);
setSearchCurrentMatch(0);
if (results.length > 0) {
viewerRef.current?.scrollToPage(results[0].pageIndex);
}
} catch (err) {
console.error("Search failed", err);
setSearchResults([]);
setSearchResultCount(0);
setSearchCurrentMatch(0);
}
@@ -46,13 +60,17 @@ function App() {
const handleSearchNext = () => {
if (searchResultCount > 0) {
setSearchCurrentMatch((prev) => (prev + 1) % searchResultCount);
const nextMatch = (searchCurrentMatch + 1) % searchResultCount;
setSearchCurrentMatch(nextMatch);
viewerRef.current?.scrollToPage(searchResults[nextMatch].pageIndex);
}
};
const handleSearchPrev = () => {
if (searchResultCount > 0) {
setSearchCurrentMatch((prev) => (prev - 1 + searchResultCount) % searchResultCount);
const prevMatch = (searchCurrentMatch - 1 + searchResultCount) % searchResultCount;
setSearchCurrentMatch(prevMatch);
viewerRef.current?.scrollToPage(searchResults[prevMatch].pageIndex);
}
};
@@ -198,11 +216,15 @@ function App() {
ref={viewerRef}
documentId={activeDoc.id}
totalPages={activeDoc.totalPages}
pageWidth={activeDoc.pageWidth}
pageHeight={activeDoc.pageHeight}
zoom={zoom}
rotation={rotation}
activeTool={activeTool}
annotations={annotations}
searchQuery={searchQuery}
searchResults={searchResults}
searchCurrentMatch={searchCurrentMatch}
onAnnotationAdded={handleAnnotationAdded}
onPageVisible={setCurrentPage}
/>
+8
View File
@@ -160,6 +160,14 @@ export const Toolbar: React.FC<ToolbarProps> = ({
Highlight
</button>
<button
onClick={() => onActiveToolChange('draw')}
className={`tool-btn draw ${activeTool === 'draw' ? 'active' : ''}`}
title="Freehand Ink Pen"
>
Draw
</button>
<button
onClick={() => onActiveToolChange('signature')}
className={`tool-btn signature ${activeTool === 'signature' ? 'active' : ''}`}
+6
View File
@@ -313,6 +313,12 @@ body {
border: 1px solid rgba(245, 158, 11, 0.4);
}
.tool-btn.draw.active {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
border: 1px solid rgba(59, 130, 246, 0.4);
}
.tool-btn.signature.active {
background: rgba(79, 70, 229, 0.2);
color: #c7d2fe;
+39 -1
View File
@@ -10,6 +10,8 @@ export interface DocumentInfo {
filename: string;
sizeBytes: number;
totalPages: number;
pageWidth: number;
pageHeight: number;
uploadedAt: string;
status: 'processing' | 'ready' | 'error';
}
@@ -21,7 +23,20 @@ export interface RenderParams {
rotation: number;
}
import type { Point, Rect } from './coordinateMapping';
import type { Point } from './coordinateMapping';
export interface SearchRect {
x: number;
y: number;
w: number;
h: number;
}
export interface SearchResult {
pageIndex: number;
rects: SearchRect[];
text: string;
}
export interface TextOverlayData {
text: string;
@@ -171,6 +186,8 @@ class GatewayService {
filename: file.name,
sizeBytes: file.size,
totalPages: 5, // Mocked total pages
pageWidth: 612,
pageHeight: 792,
uploadedAt: new Date().toISOString(),
status: 'ready',
});
@@ -248,6 +265,21 @@ class GatewayService {
return response.json();
}
async searchDocument(documentId: string, query: string): Promise<SearchResult[]> {
if (!query) return [];
const urlParams = new URLSearchParams({ q: query });
const response = await fetch(`${this.baseUrl}/documents/${documentId}/search?${urlParams.toString()}`);
if (response.status === 501) {
// Return mock empty results if backend isn't available
return [];
}
if (!response.ok) throw new Error(`Failed to search document: ${response.statusText}`);
return response.json();
}
private getMockDocuments(): DocumentInfo[] {
return [
{
@@ -255,6 +287,8 @@ class GatewayService {
filename: 'Quarterly_Financial_Report.pdf',
sizeBytes: 1024 * 1024 * 3.4, // 3.4MB
totalPages: 12,
pageWidth: 612,
pageHeight: 792,
uploadedAt: new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(),
status: 'ready',
},
@@ -263,6 +297,8 @@ class GatewayService {
filename: 'Engineering_Specification_v4.pdf',
sizeBytes: 1024 * 1024 * 18.2, // 18.2MB
totalPages: 54,
pageWidth: 612,
pageHeight: 792,
uploadedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(),
status: 'ready',
},
@@ -271,6 +307,8 @@ class GatewayService {
filename: 'Tenant_Lease_Agreement_Final.pdf',
sizeBytes: 1024 * 245, // 245KB
totalPages: 4,
pageWidth: 612,
pageHeight: 792,
uploadedAt: new Date(Date.now() - 1000 * 60 * 45).toISOString(),
status: 'ready',
}
+2 -1
View File
@@ -26,7 +26,8 @@ class WasmLoader {
try {
// Dynamic import from the public folder / static route
// @ts-ignore
const createModule = (await import(/* @vite-ignore */ '/pdfengine.mjs')).default;
const moduleUrl = (await import('/pdfengine.mjs?url')).default;
const createModule = (await import(/* @vite-ignore */ moduleUrl)).default;
const Module = await createModule({
locateFile: (path: string) => {
if (path.endsWith('.wasm')) {
+19 -1
View File
@@ -3,11 +3,12 @@ import type { Rect } from '../lib/coordinateMapping';
export interface Annotation {
id: string;
type: 'highlight' | 'signature' | 'strikeout' | 'comment';
type: 'highlight' | 'signature' | 'strikeout' | 'comment' | 'ink';
bbox: Rect;
color?: string;
author: string;
content?: string;
paths?: { x: number; y: number }[][];
}
interface AnnotationLayerProps {
@@ -59,6 +60,23 @@ export const AnnotationLayer: React.FC<AnnotationLayerProps> = ({
/>
);
})}
{/* Ink Annotations */}
<svg
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none' }}
>
{annotations
.filter((anno) => anno.type === 'ink' && anno.paths)
.map((anno) => (
<g key={anno.id} stroke={anno.color || '#3b82f6'} strokeWidth={2 * zoom} fill="none" strokeLinecap="round" strokeLinejoin="round">
{anno.paths!.map((path, i) => {
if (path.length === 0) return null;
const d = path.map((pt, j) => `${j === 0 ? 'M' : 'L'} ${pt.x * zoom} ${pt.y * zoom}`).join(' ');
return <path key={i} d={d} />;
})}
</g>
))}
</svg>
</div>
);
};
+88 -6
View File
@@ -1,11 +1,13 @@
import React from 'react';
import React, { useState, useRef } from 'react';
import type { Annotation } from './AnnotationLayer';
interface OverlayLayerProps {
pageIndex: number;
width: number;
height: number;
activeTool: string;
onDrawStroke?: (path: string) => void;
zoom: number;
onAnnotationAdded?: (anno: Annotation) => void;
}
export const OverlayLayer: React.FC<OverlayLayerProps> = ({
@@ -13,11 +15,70 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
width,
height,
activeTool,
zoom,
onAnnotationAdded,
}) => {
const [isDrawing, setIsDrawing] = useState(false);
const [currentPath, setCurrentPath] = useState<{ x: number; y: number }[]>([]);
const svgRef = useRef<SVGSVGElement>(null);
const getCoordinates = (e: React.MouseEvent | MouseEvent) => {
if (!svgRef.current) return { x: 0, y: 0 };
const rect = svgRef.current.getBoundingClientRect();
return {
x: (e.clientX - rect.left) / zoom,
y: (e.clientY - rect.top) / zoom,
};
};
const handlePointerDown = (e: React.PointerEvent) => {
if (activeTool !== 'draw') return;
setIsDrawing(true);
e.target.setPointerCapture?.(e.pointerId);
setCurrentPath([getCoordinates(e)]);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!isDrawing || activeTool !== 'draw') return;
setCurrentPath((prev) => [...prev, getCoordinates(e)]);
};
const handlePointerUp = (e: React.PointerEvent) => {
if (!isDrawing || activeTool !== 'draw') return;
setIsDrawing(false);
e.target.releasePointerCapture?.(e.pointerId);
if (currentPath.length > 1) {
// Calculate bounding box
const xs = currentPath.map(p => p.x);
const ys = currentPath.map(p => p.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const newAnno: Annotation = {
id: `anno_${Math.random().toString(36).substring(2, 11)}`,
type: 'ink',
bbox: {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
},
author: 'Current User',
paths: [currentPath],
color: '#3b82f6', // Default blue color for now
};
onAnnotationAdded?.(newAnno);
}
setCurrentPath([]);
};
return (
<div
className="overlay-layer"
style={{ width: `${width}px`, height: `${height}px` }}
style={{ width: `${width}px`, height: `${height}px`, pointerEvents: activeTool === 'draw' ? 'auto' : 'none' }}
>
{/* Signature overlay state indicator */}
{activeTool === 'signature' && (
@@ -29,9 +90,30 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
)}
{activeTool === 'draw' && (
<div className="overlay-toast animate-pulse">
Freehand Ink Pen Enabled
</div>
<>
<div className="overlay-toast animate-pulse" style={{ pointerEvents: 'none' }}>
Freehand Ink Pen Enabled
</div>
<svg
ref={svgRef}
style={{ width: '100%', height: '100%', position: 'absolute', top: 0, left: 0, cursor: 'crosshair', touchAction: 'none' }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{currentPath.length > 0 && (
<path
d={currentPath.map((pt, i) => `${i === 0 ? 'M' : 'L'} ${pt.x * zoom} ${pt.y * zoom}`).join(' ')}
stroke="#3b82f6"
strokeWidth={2 * zoom}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
</svg>
</>
)}
</div>
);
+16 -3
View File
@@ -7,15 +7,20 @@ import { OverlayLayer } from './OverlayLayer';
import { SearchOverlayLayer } from './SearchOverlayLayer';
import type { Rect } from '../lib/coordinateMapping';
import { gatewayService } from '../lib/gatewayService';
import type { SearchResult } from '../lib/gatewayService';
interface PDFViewerProps {
documentId: string;
totalPages: number;
pageWidth: number;
pageHeight: number;
zoom: number;
rotation: number;
activeTool: string;
annotations: Annotation[];
searchQuery?: string;
searchResults?: SearchResult[];
searchCurrentMatch?: number;
onAnnotationAdded?: (anno: Annotation) => void;
onPageVisible?: (pageIndex: number) => void;
}
@@ -36,11 +41,15 @@ export interface PDFViewerRef {
export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
documentId,
totalPages,
pageWidth,
pageHeight,
zoom,
rotation,
activeTool,
annotations,
searchQuery,
searchResults,
searchCurrentMatch,
onAnnotationAdded,
onPageVisible,
}, ref) => {
@@ -49,9 +58,9 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
const [renderedPages, setRenderedPages] = useState<string[]>([]);
const [containerHeight, setContainerHeight] = useState(800);
// Standard Page Dimensions: Letter size is 612x792 pt
const basePageWidth = 612;
const basePageHeight = 792;
// Use provided dimensions or fallback to Letter size
const basePageWidth = pageWidth || 612;
const basePageHeight = pageHeight || 792;
const pageGap = 24; // space between pages
// Calculate layout coordinates for all pages sequentially
@@ -298,6 +307,8 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
width={page.width}
height={page.height}
activeTool={activeTool}
zoom={zoom}
onAnnotationAdded={onAnnotationAdded}
/>
{/* Search highlights overlay */}
@@ -307,6 +318,8 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
height={page.height}
zoom={zoom}
searchQuery={searchQuery || ''}
searchResults={searchResults}
searchCurrentMatch={searchCurrentMatch}
/>
</>
) : (
+34 -33
View File
@@ -1,4 +1,5 @@
import React from 'react';
import type { SearchResult } from '../lib/gatewayService';
interface SearchOverlayLayerProps {
pageIndex: number;
@@ -6,6 +7,8 @@ interface SearchOverlayLayerProps {
height: number;
zoom: number;
searchQuery: string;
searchResults?: SearchResult[];
searchCurrentMatch?: number;
}
export const SearchOverlayLayer: React.FC<SearchOverlayLayerProps> = ({
@@ -14,47 +17,45 @@ export const SearchOverlayLayer: React.FC<SearchOverlayLayerProps> = ({
height,
zoom,
searchQuery,
searchResults = [],
searchCurrentMatch = 0,
}) => {
if (!searchQuery) return null;
if (!searchQuery || searchResults.length === 0) return null;
// Mock some search results based on the query for Phase 0/1
// We'll just generate deterministic-looking boxes so it looks like it found something
const mockResults = [];
const hash = searchQuery.length + pageIndex;
if (hash % 3 !== 0) {
mockResults.push({
x: 100 * zoom,
y: (150 + hash * 10) * zoom,
width: 120 * zoom,
height: 18 * zoom,
});
}
if (hash % 2 === 0) {
mockResults.push({
x: 300 * zoom,
y: (250 + hash * 5) * zoom,
width: 80 * zoom,
height: 18 * zoom,
});
}
// Filter results for this specific page and keep track of global index
const pageMatches = searchResults
.map((result, globalIndex) => ({ ...result, globalIndex }))
.filter((result) => result.pageIndex === pageIndex);
if (pageMatches.length === 0) return null;
return (
<div
className="absolute top-0 left-0 pointer-events-none z-20"
style={{ width: `${width}px`, height: `${height}px` }}
>
{mockResults.map((rect, idx) => (
<div
key={idx}
className="absolute bg-yellow-400/40 border border-yellow-500/60 rounded-sm"
style={{
left: `${rect.x}px`,
top: `${rect.y}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
}}
/>
{pageMatches.map((match) => (
<React.Fragment key={match.globalIndex}>
{match.rects.map((rect, rectIdx) => {
const isActive = match.globalIndex === searchCurrentMatch;
return (
<div
key={rectIdx}
className={`absolute rounded-sm border ${
isActive
? 'bg-orange-500/50 border-orange-600/80 shadow-[0_0_8px_rgba(249,115,22,0.6)] z-30'
: 'bg-yellow-400/40 border-yellow-500/60'
}`}
style={{
left: `${rect.x * zoom}px`,
top: `${rect.y * zoom}px`,
width: `${rect.w * zoom}px`,
height: `${rect.h * zoom}px`,
}}
/>
);
})}
</React.Fragment>
))}
</div>
);
+101 -1
View File
@@ -12,6 +12,8 @@ class DocumentInfoResponse(BaseModel):
filename: str
sizeBytes: int
totalPages: int
pageWidth: float
pageHeight: float
uploadedAt: str
status: str
@@ -33,6 +35,8 @@ async def upload_document(file: UploadFile = File(...), password: str = "") -> D
filename=info["filename"],
sizeBytes=info["sizeBytes"],
totalPages=info["totalPages"],
pageWidth=info["pageWidth"],
pageHeight=info["pageHeight"],
uploadedAt=info["uploadedAt"],
status=info["status"]
)
@@ -62,6 +66,8 @@ def list_documents() -> List[DocumentInfoResponse]:
filename=d["filename"],
sizeBytes=d["sizeBytes"],
totalPages=d["totalPages"],
pageWidth=d.get("pageWidth", 612.0),
pageHeight=d.get("pageHeight", 792.0),
uploadedAt=d["uploadedAt"],
status=d["status"]
)
@@ -85,6 +91,8 @@ def get_document(document_id: str) -> DocumentInfoResponse:
filename=d["filename"],
sizeBytes=d["sizeBytes"],
totalPages=d["totalPages"],
pageWidth=d.get("pageWidth", 612.0),
pageHeight=d.get("pageHeight", 792.0),
uploadedAt=d["uploadedAt"],
status=d["status"]
)
@@ -202,4 +210,96 @@ def get_document_fonts(document_id: str, start_page: int = 0, end_page: int = -1
except IndexError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
class SearchRect(BaseModel):
x: float
y: float
w: float
h: float
class SearchMatch(BaseModel):
pageIndex: int
rects: List[SearchRect]
text: str
@router.get("/{document_id}/search", response_model=List[SearchMatch])
def search_document(document_id: str, q: str) -> List[SearchMatch]:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available."
)
if not q:
return []
doc_info = document_store.get_document(document_id)
if not doc_info:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
try:
doc = doc_info["doc_instance"]
matches = []
lower_query = q.lower()
query_len = len(lower_query)
for page_idx in range(doc.page_count):
page = doc.get_page(page_idx)
glyphs = page.extract_text_with_bounds()
if not glyphs:
continue
text_str = ""
char_to_glyph = []
for i, g in enumerate(glyphs):
s = g.get("text", "")
start_len = len(text_str)
text_str += s
for _ in range(len(text_str) - start_len):
char_to_glyph.append(i)
lower_text = text_str.lower()
idx = 0
while True:
idx = lower_text.find(lower_query, idx)
if idx == -1:
break
start_glyph_idx = char_to_glyph[idx]
end_glyph_idx = char_to_glyph[idx + query_len - 1]
rects = []
current_rect = None
for g_idx in range(start_glyph_idx, end_glyph_idx + 1):
g = glyphs[g_idx]
dom_y = page.height - (g["y"] + g["h"])
if current_rect is None:
current_rect = {"x": g["x"], "y": dom_y, "w": g["w"], "h": g["h"]}
else:
if abs(dom_y - current_rect["y"]) < g.get("fontSize", 12) * 0.5:
max_x = max(current_rect["x"] + current_rect["w"], g["x"] + g["w"])
current_rect["w"] = max_x - current_rect["x"]
current_rect["y"] = min(current_rect["y"], dom_y)
current_rect["h"] = max(current_rect["h"], g["h"])
else:
rects.append(SearchRect(**current_rect))
current_rect = {"x": g["x"], "y": dom_y, "w": g["w"], "h": g["h"]}
if current_rect:
rects.append(SearchRect(**current_rect))
matches.append(SearchMatch(
pageIndex=page_idx,
rects=rects,
text=text_str[idx:idx + query_len]
))
idx += 1
return matches
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
+12 -1
View File
@@ -11,12 +11,23 @@ class DocumentStore:
def add_document(self, filename: str, bytes_data: bytes, doc_instance: Any) -> Dict[str, Any]:
doc_id = str(uuid.uuid4())
uploaded_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
page_width = 612.0
page_height = 792.0
if doc_instance.page_count > 0:
try:
page_0 = doc_instance.get_page(0)
page_width = page_0.width
page_height = page_0.height
except Exception:
pass
info = {
"id": doc_id,
"filename": filename,
"sizeBytes": len(bytes_data),
"totalPages": doc_instance.page_count,
"pageWidth": page_width,
"pageHeight": page_height,
"uploadedAt": uploaded_at,
"status": "ready",
"doc_instance": doc_instance,
+10 -9
View File
@@ -43,19 +43,20 @@ if (-not $vcpkgRoot) {
Write-Host "VCPKG_ROOT defaulted to: $vcpkgRoot" -ForegroundColor Yellow
}
$vcvars = "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvars64.bat"
if (-not (Test-Path $vcvars)) {
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
if (Test-Path $vswhere) {
$vsPath = (& $vswhere -latest -prerelease -products * -property installationPath | Select-Object -First 1)
if ($vsPath) {
$vcvars = Join-Path $vsPath "VC\Auxiliary\Build\vcvars64.bat"
}
}
if (-not $vcvars -or -not (Test-Path $vcvars)) {
$vcvars = "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat"
}
if (-not (Test-Path $vcvars)) {
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
if (Test-Path $vswhere) {
$vsPath = (& $vswhere -latest -prerelease -products * -property installationPath | Select-Object -First 1)
if ($vsPath) {
$vcvars = Join-Path $vsPath "VC\Auxiliary\Build\vcvars64.bat"
}
}
$vcvars = "C:\Program Files\Microsoft Visual Studio\18\Community\VC\Auxiliary\Build\vcvars64.bat"
}
if (-not (Test-Path $vcvars)) {