@meistrari/tela-build 1.27.0 → 1.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -330,3 +330,139 @@ This is useful when you want to prevent fullscreen functionality in certain moda
|
|
|
330
330
|
- Labels and placeholders support screen readers
|
|
331
331
|
- PDF controls (zoom, page, fullscreen) are keyboard-accessible
|
|
332
332
|
- Error and loading states are communicated in the content area
|
|
333
|
+
|
|
334
|
+
## Citation Highlighting
|
|
335
|
+
|
|
336
|
+
When a user clicks on a field in a structured output, the PDF preview navigates to the correct page and highlights the exact text that the LLM cited as the source.
|
|
337
|
+
|
|
338
|
+
- `resolveCitationReferences(citations, files)` — resolves `attachment_index` to file URLs
|
|
339
|
+
- `useCitationNavigation({ citations })` — manages highlight state
|
|
340
|
+
- `usePdfLoader()` — creates PDF document handle for rendering
|
|
341
|
+
- `CitationTarget` interface — `{ file, page, literal?, rationale? }`
|
|
342
|
+
|
|
343
|
+
### Citation Data Flow
|
|
344
|
+
|
|
345
|
+
```
|
|
346
|
+
LLM returns raw citations (attachment_index + page + literal)
|
|
347
|
+
↓
|
|
348
|
+
resolveCitationReferences(rawCitations, files)
|
|
349
|
+
→ replaces attachment_index with file URL from files array
|
|
350
|
+
↓
|
|
351
|
+
useCitationNavigation({ citations: resolvedCitations })
|
|
352
|
+
→ provides navigateToCitation(path, value), clearCitation()
|
|
353
|
+
→ exposes reactive refs: highlightText, highlightPage, highlightExact, activeFile
|
|
354
|
+
↓
|
|
355
|
+
User clicks a field with citation
|
|
356
|
+
→ navigateToCitation("fieldPath")
|
|
357
|
+
→ sets activeFile, highlightPage (page + 1), highlightText (literal), highlightExact (true)
|
|
358
|
+
↓
|
|
359
|
+
TelaPreview receives highlight props
|
|
360
|
+
→ renders PDF page with yellow highlight on matching text
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Raw Citation Format (from LLM)
|
|
364
|
+
|
|
365
|
+
```json
|
|
366
|
+
{
|
|
367
|
+
"companyName": {
|
|
368
|
+
"attachment_index": 0,
|
|
369
|
+
"page": 0,
|
|
370
|
+
"literal": "ACME Corporation Ltd."
|
|
371
|
+
},
|
|
372
|
+
"items": [
|
|
373
|
+
{
|
|
374
|
+
"attachment_index": 0,
|
|
375
|
+
"page": 2,
|
|
376
|
+
"literal": "Five year term agreement"
|
|
377
|
+
}
|
|
378
|
+
]
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
- `attachment_index`: 0-based index into the files array
|
|
383
|
+
- `page`: 0-based page index (first page = 0)
|
|
384
|
+
- `literal`: exact text span copied from the source document
|
|
385
|
+
|
|
386
|
+
### Citation Usage Example
|
|
387
|
+
|
|
388
|
+
```vue
|
|
389
|
+
<script setup lang="ts">
|
|
390
|
+
import { resolveCitationReferences } from '@meistrari/tela-build/utils/citations'
|
|
391
|
+
|
|
392
|
+
// 1. Resolve citations: map attachment_index → file URL
|
|
393
|
+
const files = ['vault://abc-123', 'vault://def-456']
|
|
394
|
+
const citations = resolveCitationReferences(rawCitations, files)
|
|
395
|
+
|
|
396
|
+
// 2. Setup composables
|
|
397
|
+
const { pdfLoader } = usePdfLoader()
|
|
398
|
+
const {
|
|
399
|
+
highlightText,
|
|
400
|
+
highlightPage,
|
|
401
|
+
highlightExact,
|
|
402
|
+
activeFile,
|
|
403
|
+
navigateToCitation,
|
|
404
|
+
clearCitation,
|
|
405
|
+
} = useCitationNavigation({
|
|
406
|
+
citations: computed(() => citations),
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
// 3. Compute active citation for the preview
|
|
410
|
+
const activeCitation = computed(() => {
|
|
411
|
+
if (!activeFile.value || highlightPage.value == null)
|
|
412
|
+
return null
|
|
413
|
+
return {
|
|
414
|
+
file: activeFile.value,
|
|
415
|
+
page: highlightPage.value,
|
|
416
|
+
text: highlightText.value ?? undefined,
|
|
417
|
+
}
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
// 4. Switch to the cited file when a citation is clicked
|
|
421
|
+
watch(activeCitation, (citation) => {
|
|
422
|
+
if (!citation) return
|
|
423
|
+
const match = fileOptions.find(opt => opt.value === citation.file)
|
|
424
|
+
if (match)
|
|
425
|
+
selectedVariable.value = match.variable
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
// 5. Handle citation clicks from your output viewer
|
|
429
|
+
function onCitationClick(path: string, value?: any) {
|
|
430
|
+
navigateToCitation(path, value)
|
|
431
|
+
}
|
|
432
|
+
</script>
|
|
433
|
+
|
|
434
|
+
<template>
|
|
435
|
+
<TelaPreview
|
|
436
|
+
:model-value="selectedVariable"
|
|
437
|
+
:file-options="fileOptions"
|
|
438
|
+
:current-file="currentFile"
|
|
439
|
+
:pdf-loader="pdfLoader"
|
|
440
|
+
:highlight-text="activeCitation?.text ?? null"
|
|
441
|
+
:highlight-page="activeCitation?.page ?? null"
|
|
442
|
+
:highlight-exact="!!activeCitation"
|
|
443
|
+
@update:model-value="onVariableChange"
|
|
444
|
+
/>
|
|
445
|
+
</template>
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
### How the Highlight Works Internally
|
|
449
|
+
|
|
450
|
+
Two modes in the PDF text layer renderer:
|
|
451
|
+
|
|
452
|
+
**Exact mode** (citations, `highlightExact = true`):
|
|
453
|
+
1. Concatenates all PDF text items on the page into a single string
|
|
454
|
+
2. Normalizes both the literal and the concatenated text (lowercase, strip accents, collapse whitespace, strip invisible Unicode)
|
|
455
|
+
3. Searches for the literal as a contiguous substring
|
|
456
|
+
4. If exact match fails (PDF text extraction lost characters), falls back to a sliding window of 3 consecutive words
|
|
457
|
+
5. Highlights matching text items with yellow background
|
|
458
|
+
|
|
459
|
+
**Fuzzy mode** (search, `highlightExact = false`):
|
|
460
|
+
1. Splits highlight text into words (>= 3 chars)
|
|
461
|
+
2. Each text item that contains or is contained by any word gets highlighted
|
|
462
|
+
|
|
463
|
+
### Citation Key Details
|
|
464
|
+
|
|
465
|
+
- **Page numbering**: `page` in citations is 0-based. `useCitationNavigation` adds 1 internally for the 1-based PDF viewer.
|
|
466
|
+
- **`path` format**: dot-separated path matching the citations structure. Examples: `"companyName"`, `"items.0"`, `"nested.field.name"`.
|
|
467
|
+
- **Clearing**: call `clearCitation()` when switching context (e.g. changing tasks, closing modals).
|
|
468
|
+
- **File matching**: the `file` value after resolution must match what `TelaPreview` uses to identify files in `fileOptions`.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MaybeRefOrGetter
|
|
1
|
+
import type { MaybeRefOrGetter } from 'vue'
|
|
2
2
|
import { nextTick, ref, toValue } from 'vue'
|
|
3
3
|
|
|
4
4
|
export interface CitationTarget {
|
|
@@ -61,7 +61,7 @@ export function useCitationNavigation(options: {
|
|
|
61
61
|
highlightExact.value = false
|
|
62
62
|
activeFile.value = null
|
|
63
63
|
|
|
64
|
-
nextTick(() => {
|
|
64
|
+
void nextTick(() => {
|
|
65
65
|
activeFile.value = citation.file
|
|
66
66
|
highlightPage.value = citation.page + 1
|
|
67
67
|
highlightText.value = citation.literal
|
|
@@ -78,10 +78,10 @@ export function useCitationNavigation(options: {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
return {
|
|
81
|
-
highlightText
|
|
82
|
-
highlightPage
|
|
83
|
-
highlightExact
|
|
84
|
-
activeFile
|
|
81
|
+
highlightText,
|
|
82
|
+
highlightPage,
|
|
83
|
+
highlightExact,
|
|
84
|
+
activeFile,
|
|
85
85
|
lookupCitation,
|
|
86
86
|
navigateToCitation,
|
|
87
87
|
clearCitation,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { onUnmounted, ref } from 'vue'
|
|
2
|
+
import type { PdfDocumentHandle } from '../components/tela/preview/types'
|
|
3
|
+
import { usePdf } from './use-pdf'
|
|
4
|
+
|
|
5
|
+
export function usePdfLoader() {
|
|
6
|
+
const pdfUrlRef = ref('')
|
|
7
|
+
const { loadPdf, renderPage, getPageDimensions } = usePdf(pdfUrlRef)
|
|
8
|
+
let currentPdfDoc: Awaited<ReturnType<typeof loadPdf>> = null
|
|
9
|
+
|
|
10
|
+
async function pdfLoader(url: string): Promise<PdfDocumentHandle | null> {
|
|
11
|
+
if (currentPdfDoc) {
|
|
12
|
+
void currentPdfDoc.destroy()
|
|
13
|
+
currentPdfDoc = null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
pdfUrlRef.value = url
|
|
17
|
+
|
|
18
|
+
const doc = await loadPdf()
|
|
19
|
+
|
|
20
|
+
if (!doc)
|
|
21
|
+
return null
|
|
22
|
+
|
|
23
|
+
currentPdfDoc = doc
|
|
24
|
+
|
|
25
|
+
const capturedDoc = doc
|
|
26
|
+
return {
|
|
27
|
+
numPages: doc.numPages,
|
|
28
|
+
async renderPage(opts: {
|
|
29
|
+
pageNum: number
|
|
30
|
+
canvas: HTMLCanvasElement
|
|
31
|
+
scale: number
|
|
32
|
+
textLayer?: HTMLDivElement | null
|
|
33
|
+
highlight?: string | null
|
|
34
|
+
highlightPage?: number | null
|
|
35
|
+
highlightExact?: boolean
|
|
36
|
+
}) {
|
|
37
|
+
return await renderPage(opts)
|
|
38
|
+
},
|
|
39
|
+
async getPageDimensions(opts: { pageNum: number, scale: number }) {
|
|
40
|
+
return await getPageDimensions(opts)
|
|
41
|
+
},
|
|
42
|
+
destroy() {
|
|
43
|
+
if (capturedDoc === currentPdfDoc) {
|
|
44
|
+
void capturedDoc.destroy()
|
|
45
|
+
currentPdfDoc = null
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
onUnmounted(() => {
|
|
52
|
+
currentPdfDoc = null
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
return { pdfLoader }
|
|
56
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import * as pdfjsLib from 'pdfjs-dist'
|
|
2
|
+
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist'
|
|
3
|
+
import type { Ref } from 'vue'
|
|
4
|
+
import { markRaw, onUnmounted, reactive, watch } from 'vue'
|
|
5
|
+
|
|
6
|
+
if (!pdfjsLib.GlobalWorkerOptions.workerSrc) {
|
|
7
|
+
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjsLib.version}/build/pdf.worker.min.mjs`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizeForMatch(text: string): string {
|
|
11
|
+
return text
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.normalize('NFD')
|
|
14
|
+
.replace(/[\u0300-\u036F]/gu, '')
|
|
15
|
+
.replace(/[\0\u00AD\u200B\uFEFF]/g, '')
|
|
16
|
+
.replace(/[\u200C\u200D]/gu, '')
|
|
17
|
+
.replace(/\s+/g, ' ')
|
|
18
|
+
.trim()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PdfState {
|
|
22
|
+
pdfDoc: PDFDocumentProxy | null
|
|
23
|
+
totalPages: number
|
|
24
|
+
isLoading: boolean
|
|
25
|
+
loadError: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RenderPageOptions {
|
|
29
|
+
pageNum: number
|
|
30
|
+
canvas: HTMLCanvasElement
|
|
31
|
+
textLayer?: HTMLDivElement | null
|
|
32
|
+
scale: number
|
|
33
|
+
highlight?: string | null
|
|
34
|
+
highlightPage?: number | null
|
|
35
|
+
/** When true, uses exact matching instead of fuzzy word-based matching */
|
|
36
|
+
highlightExact?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function usePdf(url: Ref<string>) {
|
|
40
|
+
const state = reactive<PdfState>({
|
|
41
|
+
pdfDoc: null,
|
|
42
|
+
totalPages: 0,
|
|
43
|
+
isLoading: true,
|
|
44
|
+
loadError: null,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
let isUnmounted = false
|
|
48
|
+
const activeRenderTasks = new Map<number, RenderTask>()
|
|
49
|
+
let isExplicitlyLoading = false
|
|
50
|
+
const activeCanvasRenders = new WeakMap<
|
|
51
|
+
HTMLCanvasElement,
|
|
52
|
+
{
|
|
53
|
+
promise: Promise<void>
|
|
54
|
+
cancel: () => void
|
|
55
|
+
pageNum: number
|
|
56
|
+
scale: number
|
|
57
|
+
hasTextLayer: boolean
|
|
58
|
+
highlight: string | null
|
|
59
|
+
highlightPage: number | null
|
|
60
|
+
highlightExact: boolean
|
|
61
|
+
}
|
|
62
|
+
>()
|
|
63
|
+
|
|
64
|
+
async function loadPdf(): Promise<PDFDocumentProxy | null> {
|
|
65
|
+
isExplicitlyLoading = true
|
|
66
|
+
if (isUnmounted)
|
|
67
|
+
return null
|
|
68
|
+
|
|
69
|
+
state.isLoading = true
|
|
70
|
+
state.loadError = null
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const loadingTask = pdfjsLib.getDocument(url.value)
|
|
74
|
+
const doc = await loadingTask.promise
|
|
75
|
+
|
|
76
|
+
if (isUnmounted) {
|
|
77
|
+
void doc.destroy()
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
state.pdfDoc = markRaw(doc)
|
|
82
|
+
state.totalPages = doc.numPages
|
|
83
|
+
state.isLoading = false
|
|
84
|
+
isExplicitlyLoading = false
|
|
85
|
+
|
|
86
|
+
return doc
|
|
87
|
+
}
|
|
88
|
+
catch (err: any) {
|
|
89
|
+
isExplicitlyLoading = false
|
|
90
|
+
if (isUnmounted)
|
|
91
|
+
return null
|
|
92
|
+
|
|
93
|
+
console.error('PDF load error:', err)
|
|
94
|
+
state.loadError = err?.message || 'Failed to load PDF'
|
|
95
|
+
state.isLoading = false
|
|
96
|
+
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function renderPage(options: RenderPageOptions): Promise<void> {
|
|
102
|
+
if (isUnmounted || !state.pdfDoc)
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
const { pageNum, canvas, textLayer, scale, highlight, highlightPage, highlightExact } = options
|
|
106
|
+
let currentRenderPromise: Promise<void> | null = null
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const previousTask = activeRenderTasks.get(pageNum)
|
|
110
|
+
if (previousTask) {
|
|
111
|
+
previousTask.cancel()
|
|
112
|
+
activeRenderTasks.delete(pageNum)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const page = await state.pdfDoc.getPage(pageNum)
|
|
116
|
+
|
|
117
|
+
if (isUnmounted)
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
const viewport = page.getViewport({ scale })
|
|
121
|
+
const context = canvas.getContext('2d')
|
|
122
|
+
if (!context)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
const existingRender = activeCanvasRenders.get(canvas)
|
|
126
|
+
if (existingRender) {
|
|
127
|
+
if (
|
|
128
|
+
existingRender.pageNum === pageNum
|
|
129
|
+
&& existingRender.scale === scale
|
|
130
|
+
&& existingRender.hasTextLayer === Boolean(textLayer)
|
|
131
|
+
&& existingRender.highlight === (highlight ?? null)
|
|
132
|
+
&& existingRender.highlightPage === (highlightPage ?? null)
|
|
133
|
+
&& existingRender.highlightExact === Boolean(highlightExact)
|
|
134
|
+
) {
|
|
135
|
+
await existingRender.promise
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
existingRender.cancel()
|
|
140
|
+
try {
|
|
141
|
+
await existingRender.promise
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Expected when we cancel an obsolete render for the same canvas.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
canvas.height = viewport.height
|
|
149
|
+
canvas.width = viewport.width
|
|
150
|
+
|
|
151
|
+
const renderTask = page.render({
|
|
152
|
+
canvasContext: context,
|
|
153
|
+
viewport,
|
|
154
|
+
})
|
|
155
|
+
activeRenderTasks.set(pageNum, renderTask)
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
await renderTask.promise
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
if (activeRenderTasks.get(pageNum) === renderTask) {
|
|
162
|
+
activeRenderTasks.delete(pageNum)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
currentRenderPromise = (async () => {
|
|
167
|
+
await renderTask.promise
|
|
168
|
+
|
|
169
|
+
if (isUnmounted)
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
if (textLayer) {
|
|
173
|
+
if (highlight) {
|
|
174
|
+
await renderTextLayer(page, viewport, textLayer, highlight, highlightPage, pageNum, highlightExact)
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
textLayer.innerHTML = ''
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
})()
|
|
181
|
+
|
|
182
|
+
activeCanvasRenders.set(canvas, {
|
|
183
|
+
promise: currentRenderPromise,
|
|
184
|
+
cancel: () => renderTask.cancel(),
|
|
185
|
+
pageNum,
|
|
186
|
+
scale,
|
|
187
|
+
hasTextLayer: Boolean(textLayer),
|
|
188
|
+
highlight: highlight ?? null,
|
|
189
|
+
highlightPage: highlightPage ?? null,
|
|
190
|
+
highlightExact: Boolean(highlightExact),
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
await currentRenderPromise
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
if ((err as Error)?.name === 'RenderingCancelledException') {
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!isUnmounted) {
|
|
201
|
+
console.error('Error rendering page:', err)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
const activeRender = activeCanvasRenders.get(canvas)
|
|
206
|
+
if (currentRenderPromise && activeRender?.promise === currentRenderPromise) {
|
|
207
|
+
activeCanvasRenders.delete(canvas)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function renderTextLayer(
|
|
213
|
+
page: PDFPageProxy,
|
|
214
|
+
viewport: any,
|
|
215
|
+
textLayer: HTMLDivElement,
|
|
216
|
+
highlight?: string | null,
|
|
217
|
+
highlightPage?: number | null,
|
|
218
|
+
pageNum?: number,
|
|
219
|
+
exact?: boolean,
|
|
220
|
+
): Promise<void> {
|
|
221
|
+
if (isUnmounted)
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
textLayer.innerHTML = ''
|
|
225
|
+
textLayer.style.width = `${viewport.width}px`
|
|
226
|
+
textLayer.style.height = `${viewport.height}px`
|
|
227
|
+
|
|
228
|
+
const textContent = await page.getTextContent()
|
|
229
|
+
|
|
230
|
+
if (isUnmounted)
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
const textItems = textContent.items as Array<{ str: string, transform: number[], width: number, height: number }>
|
|
234
|
+
|
|
235
|
+
const searchWords: string[] = []
|
|
236
|
+
const shouldHighlight = highlight && (!highlightPage || highlightPage === pageNum)
|
|
237
|
+
|
|
238
|
+
let exactMatchedIndices: Set<number> | null = null
|
|
239
|
+
|
|
240
|
+
if (shouldHighlight && highlight) {
|
|
241
|
+
if (exact) {
|
|
242
|
+
const normalizedSearch = normalizeForMatch(highlight)
|
|
243
|
+
|
|
244
|
+
const ranges: { start: number, end: number }[] = []
|
|
245
|
+
let concat = ''
|
|
246
|
+
for (const item of textItems) {
|
|
247
|
+
const normalized = normalizeForMatch(item.str)
|
|
248
|
+
if (concat.length > 0 && normalized.length > 0)
|
|
249
|
+
concat += ' '
|
|
250
|
+
const start = concat.length
|
|
251
|
+
concat += normalized
|
|
252
|
+
ranges.push({ start, end: concat.length })
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
exactMatchedIndices = new Set<number>()
|
|
256
|
+
let searchFrom = 0
|
|
257
|
+
while (normalizedSearch.length > 0 && searchFrom <= concat.length - normalizedSearch.length) {
|
|
258
|
+
const idx = concat.indexOf(normalizedSearch, searchFrom)
|
|
259
|
+
if (idx === -1)
|
|
260
|
+
break
|
|
261
|
+
const matchEnd = idx + normalizedSearch.length
|
|
262
|
+
for (let i = 0; i < ranges.length; i++) {
|
|
263
|
+
if (ranges[i]!.end > idx && ranges[i]!.start < matchEnd)
|
|
264
|
+
exactMatchedIndices.add(i)
|
|
265
|
+
}
|
|
266
|
+
searchFrom = idx + 1
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Fallback: PDF text extraction can lose characters (e.g. \u0000 replacing
|
|
270
|
+
// letters), so the full literal won't match as a substring. Use a sliding
|
|
271
|
+
// window of consecutive words — windows without corrupted words still match.
|
|
272
|
+
if (exactMatchedIndices.size === 0) {
|
|
273
|
+
const words = normalizedSearch.split(/\s+/)
|
|
274
|
+
const windowSize = Math.min(3, words.length)
|
|
275
|
+
for (let w = 0; w <= words.length - windowSize; w++) {
|
|
276
|
+
const segment = words.slice(w, w + windowSize).join(' ')
|
|
277
|
+
if (segment.length < 4)
|
|
278
|
+
continue
|
|
279
|
+
let segFrom = 0
|
|
280
|
+
while (segFrom <= concat.length - segment.length) {
|
|
281
|
+
const idx = concat.indexOf(segment, segFrom)
|
|
282
|
+
if (idx === -1)
|
|
283
|
+
break
|
|
284
|
+
const matchEnd = idx + segment.length
|
|
285
|
+
for (let i = 0; i < ranges.length; i++) {
|
|
286
|
+
if (ranges[i]!.end > idx && ranges[i]!.start < matchEnd)
|
|
287
|
+
exactMatchedIndices.add(i)
|
|
288
|
+
}
|
|
289
|
+
segFrom = idx + 1
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
const normalized = normalizeForMatch(highlight).replace(/[^\w\s]/g, ' ')
|
|
296
|
+
|
|
297
|
+
normalized.split(/\s+/).forEach((word) => {
|
|
298
|
+
if (word.length >= 3)
|
|
299
|
+
searchWords.push(word)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
if (normalized.length >= 3)
|
|
303
|
+
searchWords.push(normalized)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
for (let itemIndex = 0; itemIndex < textItems.length; itemIndex++) {
|
|
308
|
+
if (isUnmounted)
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
const item = textItems[itemIndex]!
|
|
312
|
+
const div = document.createElement('span')
|
|
313
|
+
const tx = pdfjsLib.Util.transform(viewport.transform, item.transform)
|
|
314
|
+
const fontHeight = Math.hypot(tx[2], tx[3])
|
|
315
|
+
|
|
316
|
+
div.textContent = item.str
|
|
317
|
+
div.style.position = 'absolute'
|
|
318
|
+
div.style.left = `${tx[4]}px`
|
|
319
|
+
div.style.top = `${tx[5] - fontHeight}px`
|
|
320
|
+
div.style.fontSize = `${fontHeight}px`
|
|
321
|
+
div.style.fontFamily = 'sans-serif'
|
|
322
|
+
div.style.color = 'transparent'
|
|
323
|
+
div.style.whiteSpace = 'nowrap'
|
|
324
|
+
|
|
325
|
+
if (item.str.trim()) {
|
|
326
|
+
let isMatch = false
|
|
327
|
+
|
|
328
|
+
if (exact && exactMatchedIndices !== null) {
|
|
329
|
+
isMatch = exactMatchedIndices.has(itemIndex)
|
|
330
|
+
}
|
|
331
|
+
else if (searchWords.length > 0) {
|
|
332
|
+
const itemText = normalizeForMatch(item.str)
|
|
333
|
+
|
|
334
|
+
if (itemText.length >= 2) {
|
|
335
|
+
isMatch = searchWords.some(word => word.includes(itemText) || itemText.includes(word))
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (isMatch) {
|
|
340
|
+
div.style.backgroundColor = 'rgba(255, 235, 59, 0.7)'
|
|
341
|
+
div.style.borderRadius = '2px'
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
textLayer.appendChild(div)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function getPageDimensions(options: { pageNum: number, scale: number }): Promise<{ width: number, height: number } | null> {
|
|
350
|
+
if (isUnmounted || !state.pdfDoc)
|
|
351
|
+
return null
|
|
352
|
+
const page = await state.pdfDoc.getPage(options.pageNum)
|
|
353
|
+
const viewport = page.getViewport({ scale: options.scale })
|
|
354
|
+
return { width: viewport.width, height: viewport.height }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function cleanup() {
|
|
358
|
+
isUnmounted = true
|
|
359
|
+
|
|
360
|
+
activeRenderTasks.forEach(task => task.cancel())
|
|
361
|
+
activeRenderTasks.clear()
|
|
362
|
+
|
|
363
|
+
if (state.pdfDoc) {
|
|
364
|
+
void state.pdfDoc.destroy()
|
|
365
|
+
state.pdfDoc = null
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
watch(url, () => {
|
|
370
|
+
if (!isExplicitlyLoading)
|
|
371
|
+
void loadPdf()
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
onUnmounted(() => {
|
|
375
|
+
cleanup()
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
state,
|
|
380
|
+
loadPdf,
|
|
381
|
+
renderPage,
|
|
382
|
+
getPageDimensions,
|
|
383
|
+
cleanup,
|
|
384
|
+
}
|
|
385
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meistrari/tela-build",
|
|
3
|
-
"version": "1.27.
|
|
3
|
+
"version": "1.27.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"app.config.ts",
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"ts-morph": "22.0.0",
|
|
61
61
|
"typescript": "5.8.2",
|
|
62
62
|
"unocss": "66.5.12",
|
|
63
|
+
"pdfjs-dist": "4.10.38",
|
|
63
64
|
"vue": "3.5.13",
|
|
64
65
|
"vue-component-meta": "3.0.8",
|
|
65
66
|
"vue-docgen-api": "4.78.0",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
function resolveAttachment(value: Record<string, any>, files: (string | null)[]): Record<string, any> {
|
|
2
|
+
const file = files[value.attachment_index] ?? undefined
|
|
3
|
+
const { attachment_index: _, ...rest } = value
|
|
4
|
+
return { ...rest, file }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function resolveCitationReferences(citations: Record<string, any>, files: (string | null)[]): Record<string, any> {
|
|
8
|
+
const resolved: Record<string, any> = {}
|
|
9
|
+
for (const [key, value] of Object.entries(citations)) {
|
|
10
|
+
if (Array.isArray(value)) {
|
|
11
|
+
resolved[key] = value.map((item) => {
|
|
12
|
+
if (typeof item !== 'object' || item === null)
|
|
13
|
+
return item
|
|
14
|
+
if ('attachment_index' in item)
|
|
15
|
+
return resolveAttachment(item, files)
|
|
16
|
+
return resolveCitationReferences(item, files)
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
else if (value && typeof value === 'object' && 'attachment_index' in value) {
|
|
20
|
+
resolved[key] = resolveAttachment(value, files)
|
|
21
|
+
}
|
|
22
|
+
else if (value && typeof value === 'object') {
|
|
23
|
+
resolved[key] = resolveCitationReferences(value, files)
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
resolved[key] = value
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return resolved
|
|
30
|
+
}
|