feat: apercu PDF via PDF.js (rendu portable, build legacy)

Remplace l'<iframe> (lecteur PDF natif du moteur) par un rendu PDF.js
sur <canvas>, une page par canvas empilee et scrollable. Rendu
identique partout (Qt WebEngine, WebView2, navigateur), worker bundle
localement par Vite -> fonctionne hors-ligne.

Utilise le build *legacy* de pdfjs-dist : il embarque les polyfills
requis par les Chromium embarques (Qt WebEngine, WebView2), plus
anciens que Chrome. Le build par defaut appelle
`Map.prototype.getOrInsertComputed` (proposition JS recente) sans
polyfill, ce qui cassait silencieusement le rendu dans la fenetre
bureau. Le catch logue desormais l'erreur au lieu de l'avaler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 17:22:20 +02:00
parent a90d1c4f64
commit 2d8b2ff42f
3 changed files with 396 additions and 38 deletions

View File

@@ -5,63 +5,142 @@
<span class="text-sm font-medium truncate">{{ fileName }}</span>
</div>
<!-- PDF via native browser viewer -->
<iframe
v-if="pdfUrl"
:src="pdfUrl"
type="application/pdf"
class="flex-1 w-full border-0"
<!-- États -->
<div
v-if="loading"
class="flex-1 flex items-center justify-center text-gray-400 text-sm"
>
Chargement du PDF
</div>
<div
v-else-if="error"
class="flex-1 flex items-center justify-center px-4 text-center text-red-400 text-sm"
>
{{ error }}
</div>
<!-- Rendu PDF.js : une <canvas> par page, empilées et scrollables.
Ne dépend pas du lecteur PDF natif du moteur (Qt WebEngine,
WebView2, navigateur) -> rendu identique partout. -->
<div
v-else-if="hasSource"
ref="container"
class="flex-1 overflow-auto p-2 bg-gray-800"
/>
<!-- Empty state -->
<div v-else class="flex-1 flex items-center justify-center text-gray-400 text-sm">
<div
v-else
class="flex-1 flex items-center justify-center text-gray-400 text-sm"
>
Aucun PDF sélectionné
</div>
</div>
</template>
<script setup>
import { ref, watch, onUnmounted } from 'vue'
import { ref, watch, onUnmounted, nextTick } from 'vue'
// Build « legacy » (et non le build par défaut) : il embarque les polyfills
// nécessaires aux moteurs Chromium embarqués (Qt WebEngine, WebView2), plus
// anciens que la dernière version de Chrome. Le build moderne utilise
// `Map.prototype.getOrInsertComputed` (proposition JS récente) sans polyfill,
// ce qui casse le rendu dans la fenêtre bureau. Voir docs pdf.js « legacy ».
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'
import PdfWorker from 'pdfjs-dist/legacy/build/pdf.worker.min.mjs?url'
// Worker bundlé localement par Vite (pas de CDN) -> fonctionne hors-ligne.
pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker
const props = defineProps({
file: {
type: File,
default: null
},
url: {
type: String,
default: null
},
fileName: {
type: String,
default: 'document.pdf'
}
file: { type: File, default: null },
url: { type: String, default: null },
fileName: { type: String, default: 'document.pdf' }
})
const pdfUrl = ref(null)
let objectUrl = null
const container = ref(null)
const loading = ref(false)
const error = ref(null)
const hasSource = ref(false)
function cleanup() {
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
objectUrl = null
let loadingTask = null
// Jeton de génération : invalide les rendus concurrents (changement de source).
let renderToken = 0
async function resolveSource() {
if (props.file) return { data: await props.file.arrayBuffer() }
if (props.url) return { url: props.url }
return null
}
async function destroyTask() {
if (loadingTask) {
try {
await loadingTask.destroy()
} catch {
/* ignore */
}
loadingTask = null
}
}
watch([() => props.file, () => props.url], ([newFile, newUrl]) => {
cleanup()
async function render() {
const token = ++renderToken
error.value = null
await destroyTask()
if (newUrl) {
pdfUrl.value = newUrl
} else if (newFile) {
objectUrl = URL.createObjectURL(newFile)
pdfUrl.value = objectUrl
} else {
pdfUrl.value = null
const source = await resolveSource()
hasSource.value = source !== null
if (!source) {
loading.value = false
return
}
}, { immediate: true })
loading.value = true
try {
loadingTask = pdfjsLib.getDocument(source)
const doc = await loadingTask.promise
if (token !== renderToken) return
// Le conteneur n'est monté qu'une fois loading=false.
loading.value = false
await nextTick()
const el = container.value
if (!el || token !== renderToken) return
el.innerHTML = ''
const targetWidth = Math.max(el.clientWidth - 16, 0)
const dpr = window.devicePixelRatio || 1
for (let n = 1; n <= doc.numPages; n++) {
if (token !== renderToken) return
const page = await doc.getPage(n)
const base = page.getViewport({ scale: 1 })
const scale = targetWidth > 0 ? targetWidth / base.width : 1
const viewport = page.getViewport({ scale })
const canvas = document.createElement('canvas')
canvas.width = Math.floor(viewport.width * dpr)
canvas.height = Math.floor(viewport.height * dpr)
canvas.style.width = '100%'
canvas.className = 'block mx-auto mb-2 bg-white shadow'
const ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
await page.render({ canvasContext: ctx, viewport }).promise
if (token !== renderToken) return
el.appendChild(canvas)
}
} catch (e) {
console.error('[PdfPreview] render error:', e?.message, e)
if (token === renderToken) {
error.value = "Impossible d'afficher le PDF."
loading.value = false
}
}
}
watch([() => props.file, () => props.url], render, { immediate: true })
onUnmounted(() => {
cleanup()
renderToken++
destroyTask()
})
</script>