1- import { useEffect , useRef , useState } from 'react' ;
1+ import { useEffect , useMemo , useRef , useState } from 'react' ;
22import type { DragEvent } from 'react' ;
3- import { GlobalWorkerOptions , getDocument } from 'pdfjs-dist' ;
3+ import { GlobalWorkerOptions , Util , getDocument } from 'pdfjs-dist' ;
44import type { PDFDocumentLoadingTask , PDFPageProxy } from 'pdfjs-dist' ;
55import workerSrc from 'pdfjs-dist/build/pdf.worker.min.mjs?url' ;
6+ import type { BBox as BBoxRect } from 'extractkit' ;
67import type { FieldEntry } from '../lib/fields' ;
78import { formatPath , formatValue } from '../lib/fields' ;
89import { bboxToStyle } from '../lib/geometry' ;
10+ import type { TextSpan } from '../lib/snap' ;
11+ import { locateField } from '../lib/snap' ;
912import { isPdf , pickFile } from '../lib/upload' ;
1013
1114GlobalWorkerOptions . workerSrc = workerSrc ;
@@ -44,40 +47,79 @@ export function DocumentViewer(props: ViewerProps) {
4447 { isPdf ( file ) ? (
4548 < PdfView file = { file } boxes = { boxes } activeKey = { activeKey } onActivate = { onActivate } />
4649 ) : (
47- < div className = "page-stack" >
48- < ImagePage
49- src = { docUrl }
50- pageIndex = { 0 }
51- boxes = { boxes }
52- activeKey = { activeKey }
53- onActivate = { onActivate }
54- />
55- </ div >
50+ < ImagePage src = { docUrl } boxes = { boxes } activeKey = { activeKey } onActivate = { onActivate } />
5651 ) }
5752 </ div >
5853 ) ;
5954}
6055
61- interface PageBoxProps {
56+ /** A field placed on a specific page, with the bbox to draw. */
57+ interface PlacedBox {
58+ entry : FieldEntry ;
59+ bbox : BBoxRect ;
60+ }
61+
62+ interface OverlayProps {
6263 boxes : FieldEntry [ ] ;
63- pageIndex : number ;
6464 activeKey : string | null ;
6565 onActivate : ( key : string | null ) => void ;
6666}
6767
68- function ImagePage ( { src, ...page } : { src : string } & PageBoxProps ) {
68+ function ImagePage ( { src, boxes, activeKey, onActivate } : { src : string } & OverlayProps ) {
69+ // Images have no text layer to snap to; draw the model's own provenance.
70+ const placed = useMemo (
71+ ( ) =>
72+ boxes
73+ . filter ( ( box ) => box . field . bbox !== null && ( box . field . page ?? 0 ) === 0 )
74+ . map ( ( box ) => ( { entry : box , bbox : box . field . bbox ! } ) ) ,
75+ [ boxes ] ,
76+ ) ;
6977 return (
70- < div className = "page" >
71- < img className = "page-image" src = { src } alt = "Uploaded document" />
72- < BoxLayer { ...page } />
78+ < div className = "page-stack" >
79+ < div className = "page" >
80+ < img className = "page-image" src = { src } alt = "Uploaded document" />
81+ < BoxLayer placed = { placed } activeKey = { activeKey } onActivate = { onActivate } />
82+ </ div >
7383 </ div >
7484 ) ;
7585}
7686
77- function PdfView ( { file, boxes, activeKey, onActivate } : { file : File } & Omit < PageBoxProps , 'pageIndex' > ) {
78- const [ pages , setPages ] = useState < PDFPageProxy [ ] > ( [ ] ) ;
87+ /** Positioned text runs of a page, normalized 0–1 with a top-left origin. */
88+ async function pageTextSpans ( page : PDFPageProxy ) : Promise < TextSpan [ ] > {
89+ const viewport = page . getViewport ( { scale : 1 } ) ;
90+ const content = await page . getTextContent ( ) ;
91+ const spans : TextSpan [ ] = [ ] ;
92+ for ( const item of content . items ) {
93+ if ( ! ( 'str' in item ) || item . str . trim ( ) === '' ) continue ;
94+ const tx = Util . transform ( viewport . transform , item . transform ) ;
95+ const fontHeight = Math . hypot ( tx [ 2 ] , tx [ 3 ] ) ;
96+ spans . push ( {
97+ text : item . str ,
98+ x0 : tx [ 4 ] / viewport . width ,
99+ y0 : ( tx [ 5 ] - fontHeight ) / viewport . height ,
100+ x1 : ( tx [ 4 ] + item . width * viewport . scale ) / viewport . width ,
101+ y1 : tx [ 5 ] / viewport . height ,
102+ } ) ;
103+ }
104+ return spans ;
105+ }
106+
107+ function PdfView ( { file, boxes, activeKey, onActivate } : { file : File } & OverlayProps ) {
108+ const [ pages , setPages ] = useState < { page : PDFPageProxy ; spans : TextSpan [ ] } [ ] > ( [ ] ) ;
79109 const [ error , setError ] = useState < string | null > ( null ) ;
80110
111+ // Place every field: snap to the page text layer, rescuing fields whose
112+ // model-reported bbox or page is missing or wrong.
113+ const placedByPage = useMemo < PlacedBox [ ] [ ] > ( ( ) => {
114+ const spansByPage = pages . map ( ( p ) => p . spans ) ;
115+ const byPage : PlacedBox [ ] [ ] = pages . map ( ( ) => [ ] ) ;
116+ for ( const entry of boxes ) {
117+ const located = locateField ( entry . field . value , entry . field . page , entry . field . bbox , spansByPage ) ;
118+ if ( located !== null ) byPage [ located . page ] ! . push ( { entry, bbox : located . bbox } ) ;
119+ }
120+ return byPage ;
121+ } , [ boxes , pages ] ) ;
122+
81123 useEffect ( ( ) => {
82124 let cancelled = false ;
83125 let loadingTask : PDFDocumentLoadingTask | null = null ;
@@ -90,7 +132,10 @@ function PdfView({ file, boxes, activeKey, onActivate }: { file: File } & Omit<P
90132 loadingTask = getDocument ( { data : new Uint8Array ( buffer ) } ) ;
91133 const doc = await loadingTask . promise ;
92134 const proxies = await Promise . all (
93- Array . from ( { length : doc . numPages } , ( _ , i ) => doc . getPage ( i + 1 ) ) ,
135+ Array . from ( { length : doc . numPages } , async ( _ , i ) => {
136+ const page = await doc . getPage ( i + 1 ) ;
137+ return { page, spans : await pageTextSpans ( page ) } ;
138+ } ) ,
94139 ) ;
95140 if ( cancelled ) return ;
96141 setPages ( proxies ) ;
@@ -108,10 +153,10 @@ function PdfView({ file, boxes, activeKey, onActivate }: { file: File } & Omit<P
108153
109154 return (
110155 < div className = "page-stack" >
111- { pages . map ( ( page , index ) => (
156+ { pages . map ( ( { page } , index ) => (
112157 < div className = "page" key = { index } >
113158 < PdfCanvas page = { page } />
114- < BoxLayer boxes = { boxes } pageIndex = { index } activeKey = { activeKey } onActivate = { onActivate } />
159+ < BoxLayer placed = { placedByPage [ index ] ! } activeKey = { activeKey } onActivate = { onActivate } />
115160 </ div >
116161 ) ) }
117162 </ div >
@@ -143,34 +188,47 @@ function PdfCanvas({ page }: { page: PDFPageProxy }) {
143188 return < canvas ref = { canvasRef } className = "page-canvas" /> ;
144189}
145190
146- function BoxLayer ( { boxes, pageIndex, activeKey, onActivate } : PageBoxProps ) {
147- const onPage = boxes . filter ( ( box ) => ( box . field . page ?? 0 ) === pageIndex ) ;
191+ function BoxLayer ( {
192+ placed,
193+ activeKey,
194+ onActivate,
195+ } : {
196+ placed : PlacedBox [ ] ;
197+ activeKey : string | null ;
198+ onActivate : ( key : string | null ) => void ;
199+ } ) {
148200 return (
149201 < div className = "box-layer" >
150- { onPage . map ( ( box ) => (
151- < BBox key = { box . key } entry = { box } active = { activeKey === box . key } onActivate = { onActivate } />
202+ { placed . map ( ( { entry, bbox } ) => (
203+ < BBox
204+ key = { entry . key }
205+ entry = { entry }
206+ bbox = { bbox }
207+ active = { activeKey === entry . key }
208+ onActivate = { onActivate }
209+ />
152210 ) ) }
153211 </ div >
154212 ) ;
155213}
156214
157215function BBox ( {
158216 entry,
217+ bbox,
159218 active,
160219 onActivate,
161220} : {
162221 entry : FieldEntry ;
222+ bbox : BBoxRect ;
163223 active : boolean ;
164224 onActivate : ( key : string | null ) => void ;
165225} ) {
166226 const ref = useRef < HTMLDivElement > ( null ) ;
167- const { bbox } = entry . field ;
168227
169228 useEffect ( ( ) => {
170229 if ( active ) ref . current ?. scrollIntoView ( { block : 'nearest' , behavior : 'smooth' } ) ;
171230 } , [ active ] ) ;
172231
173- if ( bbox === null ) return null ;
174232 return (
175233 < div
176234 ref = { ref }
0 commit comments