feat(apercu): revient au lecteur PDF natif du moteur

Le rendu maison sur <canvas> dimensionnait chaque page à la largeur du
volet, sans jamais pouvoir aller au-delà : sur une page A4 (595,3 pt) dans
un volet de 495 px — soit 40 % d'une fenêtre bureau de 1280 px — cela
faisait une échelle de 0,83x, et les tableaux du CRG composés en 7 pt
tombaient à 5,8 px de haut. Illisible, sans recours : aucun zoom, aucun
re-rendu au redimensionnement (la canvas était simplement étirée en CSS),
et devicePixelRatio à 1 sur écran non-HiDPI, donc aucun suréchantillonnage.

L'<iframe> rend au moteur le zoom, la recherche, l'impression et la
sélection de texte, tous absents jusqu'ici, et redessine à chaque niveau
de zoom. Le document s'ouvre à `#zoom=100` : « ajuster à la largeur » ne
sauverait rien dans un volet étroit (54 %, soit 5 px pour du 7 pt) alors
qu'à 100 % le 7 pt fait 9,3 px. Les mots-clés `page-width`, `view=FitH` et
`pagemode=none` sont ignorés par le lecteur de Chrome ; une valeur
numérique est honorée.

Un lien « ouvrir dans un nouvel onglet » sert de porte de sortie si le
moteur n'embarque pas de lecteur PDF.

pdfjs-dist n'a plus d'utilisateur : bundle principal 966 Ko -> 492 Ko, et
le worker de 1,3 Mo n'est plus émis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 21:09:31 +02:00
parent b15f5c95b6
commit e2e2f5bc0e
4 changed files with 117 additions and 402 deletions

View File

@@ -1,53 +1,51 @@
<template>
<div class="flex flex-col h-full bg-gray-950 overflow-hidden relative">
<!-- Header -->
<div class="flex-shrink-0 flex items-center px-3 py-2 bg-gray-900 border-b border-gray-700 text-white">
<span class="text-sm font-medium truncate">{{ fileName }}</span>
<div class="flex flex-col h-full bg-gray-950 overflow-hidden">
<!-- Bandeau -->
<div class="flex-shrink-0 flex items-center gap-2 px-3 py-2 bg-gray-900 border-b border-gray-700 text-white">
<span class="text-sm font-medium truncate flex-1">{{ fileName }}</span>
<!-- Porte de sortie : si le moteur n'a pas de lecteur PDF intégré, le
volet reste vide et ce lien reste le seul moyen d'ouvrir le
document. Il sert aussi à le lire en grand sans quitter la page. -->
<a
v-if="pdfUrl"
:href="pdfUrl"
target="_blank"
rel="noopener"
class="flex-shrink-0 p-1 rounded text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
title="Ouvrir le PDF dans un nouvel onglet"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
<!-- États -->
<div
v-if="loading"
class="flex-1 flex items-center justify-center text-gray-500 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 custom-scrollbar p-2 bg-gray-950"
/>
<!-- Empty state -->
<div
v-else
class="flex-1 flex items-center justify-center text-gray-500 text-sm"
>
Aucun PDF sélectionné
<div class="flex-1 relative overflow-hidden">
<!-- Lecteur PDF natif du moteur : il apporte le zoom, la recherche, la
sélection de texte et l'impression sans code de notre côté, et
redessine à chaque niveau de zoom (donc net partout). Un rendu
maison sur <canvas> était figé à la largeur du volet, soit 0,83x
sur une page A4 dans une fenêtre de 1280 px : les tableaux du CRG
en 7 pt tombaient à 6 px de haut, illisibles. -->
<iframe
v-if="pdfUrl"
:key="pdfUrl"
:src="urlLecteur"
type="application/pdf"
:title="fileName"
class="absolute inset-0 w-full h-full border-0"
/>
<!-- Aucune source -->
<div v-else class="h-full flex items-center justify-center text-gray-500 text-sm">
Aucun PDF sélectionné
</div>
</div>
</div>
</template>
<script setup>
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
import { computed, ref, watch, onUnmounted } from 'vue'
const props = defineProps({
file: { type: File, default: null },
@@ -55,92 +53,44 @@ const props = defineProps({
fileName: { type: String, default: 'document.pdf' }
})
const container = ref(null)
const loading = ref(false)
const error = ref(null)
const hasSource = ref(false)
const pdfUrl = ref(null)
let objectUrl = null
let loadingTask = null
// Jeton de génération : invalide les rendus concurrents (changement de source).
let renderToken = 0
/** Ouverture à l'échelle 1:1. Le zoom par défaut du lecteur intégré tombe vers
35-40 % dans un volet étroit, ce qui réduit les tableaux du CRG (7 pt) à
3 px de haut. « Ajuster à la largeur » ne sauve rien ici dans un volet de
430 px cela ne donne que 54 %, soit 5 px alors qu'à 100 % le 7 pt fait
9,3 px et redevient lisible, quitte à défiler tant que le volet est étroit.
Les mots-clés `page-width`, `view=FitH` et `pagemode=none` sont ignorés par
le lecteur de Chrome ; une valeur numérique, elle, est bien honorée
(vérifié à la main). Le reste se règle dans le lecteur lui-même : boutons
/ + pour le zoom, ☰ pour replier la barre latérale de vignettes. Ces deux
choix sont mémorisés par le moteur, d' le profil persistant imposé à la
fenêtre bureau (voir `desktop.py`). */
const urlLecteur = computed(() => (pdfUrl.value ? `${pdfUrl.value}#zoom=100` : null))
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
function libererObjectUrl() {
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
objectUrl = null
}
}
async function render() {
const token = ++renderToken
error.value = null
await destroyTask()
const source = await resolveSource()
hasSource.value = source !== null
if (!source) {
loading.value = false
return
}
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)
watch(
[() => props.file, () => props.url],
([fichier, url]) => {
libererObjectUrl()
if (url) {
pdfUrl.value = url
} else if (fichier) {
objectUrl = URL.createObjectURL(fichier)
pdfUrl.value = objectUrl
} else {
pdfUrl.value = null
}
} catch (e) {
console.error('[PdfPreview] render error:', e?.message, e)
if (token === renderToken) {
error.value = "Impossible d'afficher le PDF."
loading.value = false
}
}
}
},
{ immediate: true }
)
watch([() => props.file, () => props.url], render, { immediate: true })
onUnmounted(() => {
renderToken++
destroyTask()
})
onUnmounted(libererObjectUrl)
</script>