@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, Ref } from 'vue'
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: highlightText as Ref<string | null>,
82
- highlightPage: highlightPage as Ref<number | null>,
83
- highlightExact: highlightExact as Ref<boolean>,
84
- activeFile: activeFile as Ref<string | null>,
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.0",
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
+ }