Files
ObsiGate/docs/features/pdf.md
T
bruno 133644a0ba
CI / lint (push) Successful in 1m36s
CI / security (push) Successful in 1m3s
CI / test (push) Failing after 3m41s
CI / build (push) Skipped
CI / e2e (push) Skipped
fix(pdf): affichage des pages du viewer PDF via iframe (BUG-060)
2026-09-17 19:39:48 -04:00

12 KiB

#74 — Support complet des documents PDF

Statut : ✅ Terminé (2026-09-07 — C3 + Range 206 + config G3 + indexation incrémentale, commit 7042307. Optionnels D2/E4/H2/I2 non retenus) Effort : 4-5 jours | Impact : 🟡 Références : Roadmap · Changelog — 2.1.0

  • Description : Prise en charge native des fichiers PDF dans ObsiGate avec parité fonctionnelle complète avec les documents Markdown : apparition dans l'arborescence, indexation full-text, visualisation inline dans le navigateur, recherche TF-IDF, et téléchargement.

  • Implémentation réelle (vérifiée 2026-09-07) :

    • Bugs corrigés (2026-09) : api_pdf_stream crashait en 500 (NameError: current_user jamais injecté) ; l'indexation incrémentale du watcher faisait read_text() sur les PDFs (garbage) ; Range/206 et pdf/info absents malgré le texte ci-dessous.
    • BUG-060 (2026-09-17) : l'affichage inline ne fonctionnait plus — la CSP durcie en BUG-034 (object-src 'none') bloquait l'<embed> du viewer (barre d'outils rendue, corps vide). Le rendu passe par une <iframe> (autorisée par frame-src 'self'), conforme à E1. Tests : tests/frontend/pdf-viewer.test.mjs + tests/e2e/pdf-viewer.spec.js.
    • GET /api/file/{vault}/pdf/info — métadonnées seules sans transférer le document (C3)
    • Stream avec Accept-Ranges + 206 Partial Content (single range, suffix-range, 416) (C2)
    • OBSIGATE_PDF_MAX_SIZE_MB (50) + OBSIGATE_PDF_EXTRACT_TIMEOUT (30s via thread-pool) (B4/G3)
    • Backend backend/pdf_reader.py (existant) — extraction pypdf + pymupdf (fallback), métadonnées, TOC
    • backend/indexer.py — .pdf dans SUPPORTED_EXTENSIONS, extraction dans index_document()
    • backend/main.py — flag is_pdf: True retourné par api_file_view, endpoint GET /api/file/{vault}/pdf/stream avec support Range/206
    • backend/search.py — filtre ext:pdf (déjà implémenté avant cette PR)
    • frontend/js/viewer.js:451-480 — branche if (data.is_pdf) + iframe + toolbar + TOC + bouton download
    • Tests : tests/test_pdf.py (26 tests verts) — text/metadata/TOC + indexation scan/incrémentale + filtre ext + stream 200/206/416 + /pdf/info + limite de taille
    • Bug fixé dans cette PR : PdfReader NameError dans pdf_reader.py quand pymupdf est installé (la variable PdfReader n'était déclarée que dans la branche except ImportError)
    • backend/requirements-test.txt (nouveau) — reportlab pour générer des PDFs de test
  • Sous-tâches :

A. Backend — Extraction de texte PDF (1-1.5 jour)

  • A1. Dépendance : pypdf>=4.0 retenu dans requirements (pure Python, simplicité Docker) ; PyMuPDF (fitz) utilisé automatiquement en priorité s'il est importable — l'inverse du plan initial, fonctionnellement équivalent.
  • A2. Module backend/pdf_reader.py : Créer un module dédié avec les fonctions :
    • extract_pdf_text(file_path: Path) -> str : extrait tout le texte du PDF, page par page, avec séparateur \f entre pages. Gère les PDF encodés, protégés par mot de passe (retourne erreur explicite), et corrompus.
    • extract_pdf_metadata(file_path: Path) -> dict : extrait titre, auteur, sujet, nombre de pages, taille.
    • extract_pdf_preview(file_path: Path, max_chars: int = 100000) -> str : extrait les N premiers caractères pour l'indexation (limité par SEARCH_CONTENT_LIMIT).
  • A3. Fallback pypdf : Si pymupdf non disponible (exception d'import), fallback automatique sur pypdf avec un log warning. Code structuré avec une interface abstraite (PdfReader protocol) pour swap transparent.

B. Backend — Indexation des PDF (1 jour)

  • B1. Ajout à SUPPORTED_EXTENSIONS : Ajouter .pdf au set dans backend/indexer.py:56. Déclencher un rebuild complet de l'index (incrémental via le file watcher pour les nouveaux PDFs).
  • B2. Lecture PDF dans les DEUX chemins d'indexation (_scan_vault + _index_single_file_sync, utilisé par le watcher) : détection .pdf → extract_pdf_text(). Fix 2026-09 : seul le scan complet gérait les PDFs, l'incrémental indexait du garbage.
  • B3. Métadonnées PDF (adapté) : titre PDF prioritaire sur le nom de fichier dans l'index ; pages/author exposés via api_file_view + /pdf/info (non stockés dans l'entrée d'index).
  • B4. Gestion d'erreur robuste : PDF corrompu → log warning + skip (ne pas bloquer l'indexation). PDF volumineux (>50 Mo) → log info + extraction tronquée à SEARCH_CONTENT_LIMIT. Timeout d'extraction configurable (30s par défaut).

C. Backend — API endpoints PDF (0.5 jour)

  • C1. Modification de api_file_view() (backend/main.py:2270) : Avant la tentative de read_text(), détecter .pdf par extension. Pour les PDF :
    • Extraire le texte avec extract_pdf_text()
    • Extraire les métadonnées (pages, auteur)
    • Retourner une réponse structurée : is_pdf: true, page_count, pdf_metadata, html (aperçu texte formaté), raw_length
    • Le champ html contient un rendu texte simple (pas de markdown) : texte paginé ou première page formatée
  • C2. Nouvel endpoint GET /api/file/{vault}/pdf/stream : Sert le fichier PDF brut avec Content-Type: application/pdf et Content-Disposition: inline pour visualisation dans le navigateur. Supporte le Range header (HTTP 206 Partial Content) pour le streaming progressif des gros PDFs — essentiel pour la performance sur des documents volumineux.
  • C3. Nouvel endpoint GET /api/file/{vault}/pdf/info : Retourne les métadonnées seules (pages, titre, auteur) sans le contenu — permet à l'UI d'afficher les infos avant de charger le PDF lourd.
  • C4. Endpoint download : Déjà fonctionnel (/api/file/{vault}/download) — aucun changement nécessaire.

D. Frontend — Arborescence de fichiers (0.5 jour)

  • D1. Icône et filtre : L'icône PDF (file-text de Lucide) est déjà mappée dans EXT_ICONS (frontend/js/utils.js:129). Une fois .pdf dans SUPPORTED_EXTENSIONS, les PDFs apparaissent automatiquement dans l'arborescence via l'API list_directory. Aucun changement UI nécessaire.
  • D2. Distinction visuelle — ⚪ NON RETENU : Sous-titre léger sous le nom du fichier dans l'arborescence indiquant le nombre de pages (ex: « 12 pages ») pour différencier rapidement les PDF des MD. Donnée disponible via l'API pdf/info.
  • D3. Drag & drop et upload : Le mécanisme d'upload existant (POST /api/file/{vault}/upload) fonctionne déjà pour tout type de fichier. Vérifier que le MIME type application/pdf est correctement détecté et que le watcher réindexe automatiquement.

E. Frontend — Viewer PDF (1 jour)

  • E1. Rendu inline natif : Utiliser le visualiseur PDF intégré du navigateur via <iframe> pointant sur /api/file/{vault}/pdf/stream?path=.... Approche optimale :
    • Zéro dépendance JS supplémentaire
    • Rendu identique à Chrome/Firefox/Safari natif
    • Support natif du zoom, recherche dans le document, navigation par pages, rotation
    • L'iframe s'adapte en hauteur (height: 100% du content-area)
  • E2. Détection dans le viewer : Dans frontend/js/viewer.js, fonction renderFileContent() — ajouter une branche après la détection data.unsupported :
    • Si data.is_pdf === true → render l'iframe PDF au lieu du viewer markdown
    • Si le navigateur ne supporte pas le rendu PDF inline → fallback sur l'UI « binaire » avec bouton download + bouton « Ouvrir dans un nouvel onglet »
  • E3. Barre d'outils PDF : Dans la barre d'outils du viewer (celle qui a déjà les boutons Copier, Source, .md, PDF, Éditer, pop-out), pour les fichiers PDF :
    • Remplacer « Copier » / « Source » / « Éditer » par des actions spécifiques PDF
    • Bouton « Télécharger » (.pdf) — déjà existant, fonctionne
    • Bouton « Plein écran » — ouvre le PDF dans un nouvel onglet en plein écran
    • Badge « N pages » indiquant le nombre de pages
    • Bouton « pop-out » — gardé, ouvre le viewer PDF dans une popup séparée
  • E4. Thème — ⚪ NON RETENU : L'iframe PDF est en dehors du DOM applicatif donc pas affecté par le thème dark/light. Ajouter un message discret « Le PDF s'affiche avec le thème de votre navigateur » si _currentTheme === 'dark' (les PDFs en fond blanc dans un thème sombre peuvent surprendre).
  • E5. Responsive : L'iframe s'adapte à la largeur du content-area. En mode mobile, hauteur ajustée à la viewport. La toolbar mobile existante fonctionne avec les actions PDF.

F. Frontend — Recherche (0.5 jour)

  • F1. Résultats de recherche : Les PDFs apparaissent dans les résultats via le TF-IDF existant (le texte extrait est indexé). Ajouter un badge visuel « PDF » à côté du titre dans les résultats de recherche pour distinguer les PDFs des MD — utiliser l'icône file-text.
  • F2. Snippets de recherche : Les extraits de contexte montrent le texte extrait du PDF avec surlignage des termes recherchés — fonctionnement identique aux MD via le mécanisme de snippet existant dans search.py.
  • F3. Filtres de recherche avancés : Ajouter ext:pdf comme filtre pour limiter la recherche aux PDFs uniquement (complément aux filtres created:, modified:, size: déjà prévus #34).

G. Docker & Dépendances (0.5 jour)

  • G1. requirements.txt : pypdf>=4.0 retenu (pymupdf optionnel, utilisé s'il est importable).
  • G2. Dockerfile : Vérifier que l'image python:3.11-slim dispose des libs système nécessaires pour pymupdf. Si besoin, ajouter libmupdf-dev ou utiliser pypdf (pure Python) pour éviter la complexité. Recommandation : pypdf pour la simplicité Docker, pymupdf en option pour la performance.
  • G3. Configuration : OBSIGATE_PDF_MAX_SIZE_MB (50) + OBSIGATE_PDF_EXTRACT_TIMEOUT (30s) — documentés dans .env.example et README FR/EN.

H. Tests (1 jour)

  • H1. Tests unitaires backend :
    • test_pdf_reader.py : extraction texte PDF simple, PDF vide, PDF avec uniquement des images (OCR non requis — retourne chaîne vide), PDF protégé par mot de passe, PDF corrompu, extraction métadonnées
    • Fixtures : créer un PDF de test minimal (2 pages, texte simple) via reportlab dans les fixtures de test
    • test_pdf_indexing.py : vérifier qu'un PDF dans un vault est correctement indexé, que le texte est recherchable, que index_document() gère l'extension .pdf
    • test_pdf_api.py : endpoint view retourne is_pdf: true, endpoint pdf/stream retourne application/pdf, endpoint pdf/info retourne les métadonnées
  • H2. Tests frontend — ⚪ NON RETENU :
    • Test d'intégration : naviguer vers un fichier PDF → l'iframe est rendue
    • Test : fichier PDF dans les résultats de recherche
    • Test : téléchargement de PDF fonctionnel
  • H3. CI : Ajouter la fixture PDF de test dans les artefacts de CI. Les tests PDF sont sautés si pymupdf/pypdf n'est pas disponible.

I. Documentation utilisateur (inclus dans l'effort)

  • I1. Mettre à jour README.md : mentionner le support PDF dans les formats supportés
  • I2. Ajouter une note dans la FAQ : « Comment visualiser un PDF dans ObsiGate ? » — ⚪ NON RETENU (README + guide couvrent déjà le support PDF)
  • I3. Documenter les limitations : pas d'OCR (PDFs scannés non recherchables), pas d'annotation PDF, pas d'édition de PDF

J. Points d'attention / Risques

  • Performance : Un PDF de 500 pages peut générer beaucoup de texte → SEARCH_CONTENT_LIMIT (100 Ko) limite l'indexation au début du document. Pour les PDFs volumineux, envisager une extraction paginée avec SEARCH_CONTENT_LIMIT réparti sur les N premières pages.
  • Sécurité : Les PDFs malveillants (injections JS, liens externes) ne sont pas exécutés dans l'iframe par défaut (sandbox du navigateur). Ajouter sandbox="allow-same-origin" sur l'iframe pour renforcer.
  • Mémoire : pymupdf charge le PDF entier en mémoire. Pour les très gros PDFs (>200 Mo), utiliser le streaming ou pypdf qui supporte la lecture paresseuse.
  • Compatibilité navigateurs : Le rendu PDF natif fonctionne sur Chrome, Firefox, Edge, Safari. Safari iOS a des limitations sur les iframes PDF. Prévoir le fallback « ouvrir dans un nouvel onglet » pour ces cas.
  • PDFs dans les vaults Obsidian : Obsidian Desktop ne gère pas nativement les PDFs (affichage via iframe système). ObsiGate apporte une valeur ajoutée en offrant la visualisation + recherche.