106 lines
12 KiB
Markdown
106 lines
12 KiB
Markdown
# #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](../ROADMAP.md) · [Changelog — 2.1.0](../../CHANGELOG.md#210--2026-09-08)
|
|
|
|
- **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)
|
|
- [x] **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.
|
|
- [x] **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`).
|
|
- [x] **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)
|
|
- [x] **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).
|
|
- [x] **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.
|
|
- [x] **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).
|
|
- [x] **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)
|
|
- [x] **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
|
|
- [x] **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.
|
|
- [x] **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.
|
|
- [x] **C4. Endpoint download** : Déjà fonctionnel (`/api/file/{vault}/download`) — aucun changement nécessaire.
|
|
|
|
## D. Frontend — Arborescence de fichiers (0.5 jour)
|
|
- [x] **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`.
|
|
- [x] **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)
|
|
- [x] **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)
|
|
- [x] **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 »
|
|
- [x] **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).
|
|
- [x] **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)
|
|
- [x] **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`.
|
|
- [x] **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`.
|
|
- [x] **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)
|
|
- [x] **G1. requirements.txt** : `pypdf>=4.0` retenu (pymupdf optionnel, utilisé s'il est importable).
|
|
- [x] **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.
|
|
- [x] **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)
|
|
- [x] **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
|
|
- [x] **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)
|
|
- [x] **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)
|
|
- [x] **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.
|