@tekkare/romulus 0.1.229 → 0.1.231

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.
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "0.1.228",
7
+ "version": "0.1.230",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -0,0 +1,521 @@
1
+ <!--
2
+ RmDocumentViewer - un document (PDF ou page HTML) affiche dans la page, avec
3
+ une recherche dans son texte.
4
+
5
+ Un document pose dans une `<iframe>` passe par le lecteur du navigateur : la
6
+ page n'a aucune prise dessus, et quand le fichier vient d'un autre domaine
7
+ elle ne peut meme pas lire son texte. Pour un compte rendu de deux cents
8
+ pages, la seule recherche possible etait Ctrl+F, que personne ne pense a
9
+ faire dans un tiroir, et qui ne savait rien des termes deja tapes dans la
10
+ page.
11
+
12
+ Le composant lit le fichier lui-meme et choisit le rendu sur son contenu,
13
+ pas sur son type annonce (les copies S3 sortent en `binary/octet-stream`,
14
+ et un proxy qui sert « du PDF » peut servir du HTML) :
15
+ - un PDF passe par pdf.js : pages dessinees a la demande, couche de texte ;
16
+ - une page HTML va dans une iframe `srcdoc` sandboxee SANS `allow-scripts`
17
+ (aucun script du document ne tourne) mais avec `allow-same-origin`, ce
18
+ qui laisse le composant parcourir son texte et y poser des surlignages
19
+ (API CSS Custom Highlight, le DOM du document n'est pas touche).
20
+
21
+ La barre en tete cherche dans le texte : surlignage de toutes les
22
+ occurrences, compteur, precedent / suivant. `query` sert de terme de
23
+ depart, pour ouvrir le document directement sur les passages qui l'ont fait
24
+ remonter dans une liste de resultats. Une liste cherche chaque terme
25
+ separement ; une chaine, et tout ce que l'utilisateur tape ensuite, cherche
26
+ une expression. La recherche ignore la casse et les accents, comme
27
+ `RmTextHighlight`.
28
+
29
+ Le fichier est lu par `fetch` : il doit venir de la meme origine ou d'un
30
+ serveur qui autorise CORS. Une app qui affiche des documents tiers les fait
31
+ passer par son propre proxy, et donne l'adresse d'origine en `base` pour
32
+ que les liens relatifs d'une page HTML pointent au bon endroit. Si le
33
+ fichier ne se lit pas (404, CORS, ni PDF ni HTML), le composant emet
34
+ `error` : a l'appelant de retomber sur une `<iframe>`.
35
+
36
+ pdf.js (~1 Mo avec son worker) n'est charge que pour un PDF.
37
+ -->
38
+ <script setup lang="ts">
39
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
40
+
41
+ const props = withDefaults(defineProps<{
42
+ /** Adresse du document, lue par `fetch`. */
43
+ src: string
44
+ /** Terme(s) cherche(s) a l'ouverture. Une liste cherche chaque terme. */
45
+ query?: string | readonly string[] | null
46
+ /**
47
+ * Adresse d'origine d'une page HTML servie par un proxy : sert de `<base>`
48
+ * aux liens relatifs. Sans effet sur un PDF.
49
+ */
50
+ base?: string | null
51
+ lang?: 'fr' | 'en'
52
+ }>(), {
53
+ query: null,
54
+ base: null,
55
+ lang: 'fr',
56
+ })
57
+
58
+ const emit = defineEmits<{ error: [error: unknown] }>()
59
+
60
+ const WORDS = {
61
+ fr: {
62
+ search: 'Rechercher dans le document',
63
+ previous: 'Occurrence précédente',
64
+ next: 'Occurrence suivante',
65
+ none: 'Aucun résultat',
66
+ loading: 'Chargement du document…',
67
+ failed: 'Le document ne s’affiche pas.',
68
+ count: (current: number, total: number) => `${current} sur ${total}`,
69
+ },
70
+ en: {
71
+ search: 'Search in document',
72
+ previous: 'Previous match',
73
+ next: 'Next match',
74
+ none: 'No match',
75
+ loading: 'Loading document…',
76
+ failed: 'The document cannot be displayed.',
77
+ count: (current: number, total: number) => `${current} of ${total}`,
78
+ },
79
+ } as const
80
+
81
+ const words = computed(() => WORDS[props.lang])
82
+
83
+ const root = ref<HTMLDivElement | null>(null)
84
+ const scroller = ref<HTMLDivElement | null>(null)
85
+ const frame = ref<HTMLIFrameElement | null>(null)
86
+ const input = ref<HTMLInputElement | null>(null)
87
+
88
+ const status = ref<'loading' | 'ready' | 'failed'>('loading')
89
+ const kind = ref<'pdf' | 'html' | null>(null)
90
+ /** Le HTML a poser dans l'iframe, `<base>` et styles de surlignage compris. */
91
+ const srcdoc = ref('')
92
+ /** Ce qui est dans le champ. */
93
+ const text = ref('')
94
+ /**
95
+ * Les termes de `query` tant que le champ n'a pas ete retouche : une liste
96
+ * affichee « a b » ne doit pas se chercher comme l'expression « a b ».
97
+ */
98
+ const terms = shallowRef<string[] | null>(null)
99
+ const matches = ref({ current: 0, total: 0 })
100
+ const pending = ref(false)
101
+
102
+ function seed() {
103
+ const q = props.query
104
+ if (Array.isArray(q)) {
105
+ const list = q.map(x => String(x).trim()).filter(Boolean)
106
+ terms.value = list.length > 1 ? list : null
107
+ text.value = list.join(' ')
108
+ }
109
+ else {
110
+ terms.value = null
111
+ text.value = String(q ?? '').trim()
112
+ }
113
+ }
114
+
115
+ /** Ce qui se cherche : la liste de depart, ou l'expression tapee. */
116
+ const searched = computed<string | string[]>(() => terms.value ?? text.value.trim())
117
+ const hasQuery = computed(() => (Array.isArray(searched.value) ? searched.value.length > 0 : searched.value !== ''))
118
+
119
+ function find(type: '' | 'again' = '', findPrevious = false) {
120
+ if (kind.value === 'pdf') findPdf(type, findPrevious)
121
+ else if (kind.value === 'html') findHtml(type, findPrevious)
122
+ }
123
+
124
+ function onInput(event: Event) {
125
+ text.value = (event.target as HTMLInputElement).value
126
+ terms.value = null
127
+ find()
128
+ }
129
+
130
+ function step(previous: boolean) {
131
+ if (hasQuery.value) find('again', previous)
132
+ }
133
+
134
+ function onKeydown(event: KeyboardEvent) {
135
+ if (event.key !== 'Enter') return
136
+ event.preventDefault()
137
+ step(event.shiftKey)
138
+ }
139
+
140
+ /** Ctrl/Cmd+F dans le lecteur va au champ plutot qu'a la recherche de la page. */
141
+ function onRootKeydown(event: KeyboardEvent) {
142
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') {
143
+ event.preventDefault()
144
+ input.value?.focus()
145
+ input.value?.select()
146
+ }
147
+ }
148
+
149
+ const countLabel = computed(() => {
150
+ if (status.value !== 'ready' || !hasQuery.value || pending.value) return ''
151
+ return matches.value.total
152
+ ? words.value.count(matches.value.current, matches.value.total)
153
+ : words.value.none
154
+ })
155
+
156
+ /* ── Chargement ─────────────────────────────────────────────────────────── */
157
+
158
+ let abort: AbortController | null = null
159
+
160
+ /** PDF, HTML ou rien, d'apres les premiers octets. */
161
+ function sniff(bytes: Uint8Array): 'pdf' | 'html' | null {
162
+ const head = new TextDecoder('latin1').decode(bytes.subarray(0, 2048))
163
+ if (head.includes('%PDF-')) return 'pdf'
164
+ if (/<(!doctype html|html|head|body)\b/i.test(head)) return 'html'
165
+ return null
166
+ }
167
+
168
+ async function load() {
169
+ destroy()
170
+ status.value = 'loading'
171
+ kind.value = null
172
+ srcdoc.value = ''
173
+ matches.value = { current: 0, total: 0 }
174
+ seed()
175
+ if (!props.src) return
176
+ abort = new AbortController()
177
+
178
+ try {
179
+ const response = await fetch(props.src, { signal: abort.signal })
180
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
181
+ const bytes = new Uint8Array(await response.arrayBuffer())
182
+ const type = sniff(bytes)
183
+ if (!type) throw new Error('Unsupported document')
184
+ kind.value = type
185
+ await nextTick()
186
+ if (type === 'pdf') await loadPdf(bytes)
187
+ else loadHtml(bytes)
188
+ }
189
+ catch (error) {
190
+ // Une annulation (fermeture pendant le chargement) n'est pas une erreur.
191
+ const name = (error as any)?.name
192
+ if (name === 'AbortError' || name === 'AbortException') return
193
+ status.value = 'failed'
194
+ emit('error', error)
195
+ }
196
+ }
197
+
198
+ function destroy() {
199
+ abort?.abort()
200
+ abort = null
201
+ destroyPdf()
202
+ destroyHtml()
203
+ }
204
+
205
+ watch(() => props.src, load)
206
+ watch(() => props.query, () => {
207
+ seed()
208
+ find()
209
+ })
210
+ onMounted(load)
211
+ onBeforeUnmount(destroy)
212
+
213
+ /* ── PDF ────────────────────────────────────────────────────────────────── */
214
+
215
+ // Objets pdf.js : hors de la reactivite, Vue n'a rien a y suivre.
216
+ let eventBus: any = null
217
+ let viewer: any = null
218
+ let loadingTask: any = null
219
+ let resizeObserver: ResizeObserver | null = null
220
+
221
+ async function loadPdf(data: Uint8Array) {
222
+ const container = scroller.value
223
+ if (!container) return
224
+ const pdfjs: any = await import('pdfjs-dist')
225
+ // Le visualiseur lit la bibliotheque sur `globalThis` : elle doit etre la
226
+ // avant son import.
227
+ ;(globalThis as any).pdfjsLib ??= pdfjs
228
+ const [{ default: workerSrc }, viewerLib] = await Promise.all([
229
+ // @ts-ignore import d URL, resolu par Vite cote app
230
+ import('pdfjs-dist/build/pdf.worker.min.mjs?url'),
231
+ import('pdfjs-dist/web/pdf_viewer.mjs'),
232
+ // @ts-ignore feuille de style, resolue par Vite cote app
233
+ import('pdfjs-dist/web/pdf_viewer.css'),
234
+ ])
235
+ pdfjs.GlobalWorkerOptions.workerSrc ||= workerSrc
236
+
237
+ const { EventBus, PDFLinkService, PDFFindController, PDFViewer } = viewerLib as any
238
+ eventBus = new EventBus()
239
+ const linkService = new PDFLinkService({ eventBus })
240
+ const findController = new PDFFindController({ eventBus, linkService })
241
+ viewer = new PDFViewer({
242
+ container,
243
+ eventBus,
244
+ linkService,
245
+ findController,
246
+ removePageBorders: true,
247
+ })
248
+ linkService.setViewer(viewer)
249
+
250
+ eventBus.on('pagesinit', () => {
251
+ viewer.currentScaleValue = 'page-width'
252
+ if (hasQuery.value) find()
253
+ })
254
+ eventBus.on('updatefindcontrolstate', (e: any) => {
255
+ pending.value = e.state === 3
256
+ matches.value = e.matchesCount
257
+ })
258
+ eventBus.on('updatefindmatchescount', (e: any) => {
259
+ matches.value = e.matchesCount
260
+ })
261
+
262
+ loadingTask = pdfjs.getDocument({ data })
263
+ const pdf = await loadingTask.promise
264
+ viewer.setDocument(pdf)
265
+ linkService.setDocument(pdf)
266
+ status.value = 'ready'
267
+
268
+ resizeObserver = new ResizeObserver(() => {
269
+ if (viewer?.pagesCount) viewer.currentScaleValue = 'page-width'
270
+ })
271
+ resizeObserver.observe(container)
272
+ }
273
+
274
+ function findPdf(type: '' | 'again', findPrevious: boolean) {
275
+ eventBus?.dispatch('find', {
276
+ source: null,
277
+ type,
278
+ query: searched.value,
279
+ caseSensitive: false,
280
+ entireWord: false,
281
+ highlightAll: true,
282
+ findPrevious,
283
+ matchDiacritics: false,
284
+ })
285
+ }
286
+
287
+ function destroyPdf() {
288
+ resizeObserver?.disconnect()
289
+ resizeObserver = null
290
+ loadingTask?.destroy()
291
+ loadingTask = null
292
+ viewer?.cleanup?.()
293
+ viewer = null
294
+ eventBus = null
295
+ const inner = scroller.value?.firstElementChild
296
+ if (inner) inner.innerHTML = ''
297
+ }
298
+
299
+ /* ── HTML ───────────────────────────────────────────────────────────────── */
300
+
301
+ const ALL = 'rm-document-match'
302
+ const CURRENT = 'rm-document-match-current'
303
+
304
+ /**
305
+ * Le texte du document, replie (minuscule, sans accent, blancs fusionnes),
306
+ * et pour chaque caractere replie le noeud texte et la position d'ou il vient.
307
+ */
308
+ let index: { text: string, nodes: Text[], node: number[], offset: number[] } | null = null
309
+ let ranges: Range[] = []
310
+ let currentMatch = -1
311
+
312
+ /** Un caractere, minuscule et sans accent, en un seul caractere. */
313
+ function foldChar(char: string): string {
314
+ return char.normalize('NFD')[0]!.toLowerCase()[0]!
315
+ }
316
+
317
+ function foldQuery(value: string): string {
318
+ let out = ''
319
+ for (const char of value.trim()) {
320
+ if (/\s/.test(char)) {
321
+ if (!out.endsWith(' ')) out += ' '
322
+ }
323
+ else out += foldChar(char)
324
+ }
325
+ return out
326
+ }
327
+
328
+ function loadHtml(bytes: Uint8Array) {
329
+ const html = new TextDecoder(charsetOf(bytes)).decode(bytes)
330
+ // Couleurs du surlignage : l'iframe ne voit pas les jetons de la page, on
331
+ // lui passe leur valeur.
332
+ const primary = root.value
333
+ ? getComputedStyle(root.value).getPropertyValue('--color-primary').trim() || 'currentColor'
334
+ : 'currentColor'
335
+ const base = props.base ? ` href="${props.base.replace(/"/g, '&quot;')}"` : ''
336
+ // Balise ecrite en deux morceaux : la balise de style en toutes lettres
337
+ // ferait croire au compilateur SFC qu'un bloc de style commence ici.
338
+ const tag = 'sty' + 'le'
339
+ const head = `<base${base} target="_blank"><${tag}>`
340
+ + `::highlight(${ALL}){background-color:color-mix(in srgb, ${primary} 20%, transparent)}`
341
+ + `::highlight(${CURRENT}){background-color:color-mix(in srgb, ${primary} 45%, transparent)}`
342
+ + `</${tag}>`
343
+ srcdoc.value = /<head[^>]*>/i.test(html)
344
+ ? html.replace(/<head[^>]*>/i, match => match + head)
345
+ : head + html
346
+ }
347
+
348
+ /** Le `charset` annonce par la page, UTF-8 sinon. */
349
+ function charsetOf(bytes: Uint8Array): string {
350
+ const head = new TextDecoder('latin1').decode(bytes.subarray(0, 4096))
351
+ const charset = head.match(/charset=["']?([\w-]+)/i)?.[1]
352
+ try {
353
+ return charset ? new TextDecoder(charset).encoding : 'utf-8'
354
+ }
355
+ catch {
356
+ return 'utf-8'
357
+ }
358
+ }
359
+
360
+ function onFrameLoad() {
361
+ const doc = frame.value?.contentDocument
362
+ if (!srcdoc.value || !doc?.body) return
363
+ indexHtml(doc)
364
+ doc.addEventListener('keydown', onRootKeydown)
365
+ status.value = 'ready'
366
+ if (hasQuery.value) find()
367
+ }
368
+
369
+ function indexHtml(doc: Document) {
370
+ const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
371
+ acceptNode: node => (['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(node.parentElement?.tagName ?? '')
372
+ ? NodeFilter.FILTER_REJECT
373
+ : NodeFilter.FILTER_ACCEPT),
374
+ })
375
+ const out = { text: '', nodes: [] as Text[], node: [] as number[], offset: [] as number[] }
376
+ let space = true
377
+ for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) {
378
+ const k = out.nodes.push(node) - 1
379
+ const data = node.data
380
+ for (let i = 0; i < data.length; i++) {
381
+ const char = data[i]!
382
+ if (/\s/.test(char)) {
383
+ if (space) continue
384
+ out.text += ' '
385
+ space = true
386
+ }
387
+ else {
388
+ out.text += foldChar(char)
389
+ space = false
390
+ }
391
+ out.node.push(k)
392
+ out.offset.push(i)
393
+ }
394
+ }
395
+ index = out
396
+ }
397
+
398
+ function findHtml(type: '' | 'again', findPrevious: boolean) {
399
+ const doc = frame.value?.contentDocument
400
+ const win = frame.value?.contentWindow
401
+ if (!doc || !win || !index) return
402
+
403
+ if (type === '') {
404
+ const queries = (Array.isArray(searched.value) ? searched.value : [searched.value])
405
+ .map(foldQuery)
406
+ .filter(Boolean)
407
+ const spans: [number, number][] = []
408
+ for (const query of queries) {
409
+ for (let at = index.text.indexOf(query); at !== -1; at = index.text.indexOf(query, at + 1)) {
410
+ spans.push([at, at + query.length])
411
+ }
412
+ }
413
+ // Dans l'ordre du document, sans chevauchement.
414
+ spans.sort((a, b) => a[0] - b[0])
415
+ ranges = []
416
+ let end = -1
417
+ for (const [start, stop] of spans) {
418
+ if (start < end) continue
419
+ end = stop
420
+ const range = doc.createRange()
421
+ range.setStart(index.nodes[index.node[start]!]!, index.offset[start]!)
422
+ range.setEnd(index.nodes[index.node[stop - 1]!]!, index.offset[stop - 1]! + 1)
423
+ ranges.push(range)
424
+ }
425
+ currentMatch = ranges.length ? 0 : -1
426
+ }
427
+ else if (ranges.length) {
428
+ currentMatch = (currentMatch + (findPrevious ? -1 : 1) + ranges.length) % ranges.length
429
+ }
430
+
431
+ const highlights = (win as any).CSS?.highlights
432
+ const Highlight = (win as any).Highlight
433
+ if (highlights && Highlight) {
434
+ highlights.set(ALL, new Highlight(...ranges))
435
+ if (currentMatch >= 0) highlights.set(CURRENT, new Highlight(ranges[currentMatch]))
436
+ else highlights.delete(CURRENT)
437
+ }
438
+
439
+ matches.value = { current: currentMatch + 1, total: ranges.length }
440
+ pending.value = false
441
+
442
+ // Defilement de l'iframe seule : `scrollIntoView` ferait aussi defiler le
443
+ // tiroir qui la contient, et la barre sortirait de l'ecran.
444
+ const range = ranges[currentMatch]
445
+ if (range) {
446
+ const rect = range.getBoundingClientRect()
447
+ win.scrollBy({ top: rect.top - win.innerHeight / 3, left: 0 })
448
+ }
449
+ }
450
+
451
+ function destroyHtml() {
452
+ frame.value?.contentDocument?.removeEventListener('keydown', onRootKeydown)
453
+ index = null
454
+ ranges = []
455
+ currentMatch = -1
456
+ }
457
+ </script>
458
+
459
+ <template>
460
+ <div ref="root" class="rm-document-viewer" @keydown="onRootKeydown">
461
+ <div class="rm-document-viewer__bar" role="search">
462
+ <label class="rm-document-viewer__field">
463
+ <RmIcon name="MagnifyingGlass" :size="14" color="var(--text-muted)" />
464
+ <input
465
+ ref="input"
466
+ class="rm-document-viewer__input"
467
+ type="search"
468
+ :value="text"
469
+ :placeholder="words.search"
470
+ :aria-label="words.search"
471
+ :disabled="status !== 'ready'"
472
+ @input="onInput"
473
+ @keydown="onKeydown"
474
+ >
475
+ </label>
476
+ <span class="rm-document-viewer__count" aria-live="polite">{{ countLabel }}</span>
477
+ <RmButton
478
+ ghost
479
+ icon-only
480
+ size="sm"
481
+ prefix-icon="CaretUp"
482
+ :aria-label="words.previous"
483
+ :title="words.previous"
484
+ :disabled="!matches.total"
485
+ @click="step(true)"
486
+ />
487
+ <RmButton
488
+ ghost
489
+ icon-only
490
+ size="sm"
491
+ prefix-icon="CaretDown"
492
+ :aria-label="words.next"
493
+ :title="words.next"
494
+ :disabled="!matches.total"
495
+ @click="step(false)"
496
+ />
497
+ </div>
498
+
499
+ <div class="rm-document-viewer__frame">
500
+ <!-- pdf.js exige un conteneur positionne en absolu qui defile, et un
501
+ enfant `.pdfViewer` ou il pose les pages. -->
502
+ <div v-show="kind === 'pdf'" ref="scroller" class="rm-document-viewer__scroller">
503
+ <div class="pdfViewer" />
504
+ </div>
505
+ <iframe
506
+ v-if="kind === 'html' && srcdoc"
507
+ ref="frame"
508
+ class="rm-document-viewer__page"
509
+ sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
510
+ :srcdoc="srcdoc"
511
+ @load="onFrameLoad"
512
+ />
513
+ <p v-if="status === 'loading'" class="rm-document-viewer__state">{{ words.loading }}</p>
514
+ <p v-else-if="status === 'failed'" class="rm-document-viewer__state">{{ words.failed }}</p>
515
+ </div>
516
+ </div>
517
+ </template>
518
+
519
+ <style scoped>
520
+ .rm-document-viewer{display:flex;flex-direction:column;gap:var(--space-8);height:100%;min-height:60vh}.rm-document-viewer__bar{align-items:center;display:flex;gap:var(--space-4)}.rm-document-viewer__field{align-items:center;background:var(--bg-surface);border:1px solid var(--border-default);border-radius:var(--radius-pill);display:flex;flex:1;gap:var(--space-8);height:32px;max-width:420px;min-width:0;padding:0 var(--space-12)}.rm-document-viewer__field:focus-within{border-color:var(--color-secondary);box-shadow:var(--shadow-focus)}.rm-document-viewer__input{background:none;border:none;color:var(--text-primary);flex:1;font-family:var(--font-sans);font-size:var(--font-13);min-width:0;outline:none}.rm-document-viewer__input::-moz-placeholder{color:var(--text-muted)}.rm-document-viewer__input::placeholder{color:var(--text-muted)}.rm-document-viewer__count{color:var(--text-secondary);font-size:var(--font-12);font-variant-numeric:tabular-nums;margin-left:var(--space-8);min-width:80px}.rm-document-viewer__frame{background:var(--bg-surface-2);border-radius:var(--radius-8,8px);flex:1;min-height:0;overflow:hidden;position:relative}.rm-document-viewer__scroller{inset:0;overflow:auto;position:absolute;--highlight-bg-color:color-mix(in srgb,var(--color-primary) 30%,transparent);--highlight-selected-bg-color:color-mix(in srgb,var(--color-primary) 65%,transparent)}.rm-document-viewer__scroller :deep(.pdfViewer .page){box-shadow:0 2px 8px rgba(var(--shadow-rgb),12%);margin:var(--space-12) auto}.rm-document-viewer__page{background:Canvas;border:none;color-scheme:light;height:100%;inset:0;position:absolute;width:100%}.rm-document-viewer__state{color:var(--text-secondary);display:grid;font-size:var(--font-13);inset:0;margin:0;place-items:center;position:absolute}
521
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekkare/romulus",
3
- "version": "0.1.229",
3
+ "version": "0.1.231",
4
4
  "description": "Romulus Design System - Nuxt module by Tekkare",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,6 +48,7 @@
48
48
  "html2canvas-pro": "^1.6.7",
49
49
  "jspdf": "^4.2.0",
50
50
  "jspdf-autotable": "^5.0.7",
51
+ "pdfjs-dist": "6.3.289",
51
52
  "write-excel-file": "^1.4.30"
52
53
  },
53
54
  "devDependencies": {