# #153 — Visionneuse & édition XLSX — état des lieux et backlog > **Item de roadmap :** [#153 — Visionneuse & édition XLSX — complétude](../ROADMAP.md) > **Origine :** #152 (visionneuse XLSX, livrée en 2.27.0 — voir > [archive/COMPLETED_v1-v2.md](../archive/COMPLETED_v1-v2.md)) > **Statut :** 🔵 En cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), P1/P2 restants > **Effort estimé :** 8-13 jours au total (P0 ✅ 2-3 j · P1 4-6 j · P2 2-4 j) > **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte > l'analyse, les risques et les critères d'acceptation. **Ne pas dupliquer le détail.** --- ## 1. Périmètre et architecture | Couche | Fichier | Rôle | |---|---|---| | Lecture | `backend/xlsx_reader.py` | `render_sheets()` → un tableau HTML par feuille (openpyxl `read_only=True`, `data_only=False`) | | Endpoint lecture | `backend/routers/files_read.py:241-265` | `GET /api/file/{vault}?path=…` → `is_xlsx: true` + `xlsx_sheets: [{name, html}]` | | Schéma API | `backend/schemas.py:286-290` | `is_xlsx`, `xlsx_sheets` | | Écriture | `backend/services/mutations.py:227-320` | `edit_xlsx_cells()` (backup, refs A1 validées, coercion `str`→`int`/`float`) | | Endpoint écriture | `backend/routers/files_write.py:116-148` | `PUT /api/file/{vault}/xlsx/save` (1 à 500 cellules / requête) | | Documentation API | `backend/openapi_docs.py:184-187` | exemple d'appel `xlsx/save` | | UI | `frontend/js/viewer.js:998-1100` | `renderXlsxViewer()` (onglets, cellules sales, Entrée/Échap, collage monoligne) | | CSS | `frontend/style.css:10927-10988` | `.xlsx-*` (variables CSS, colonne A `sticky`) | | Indexation | `backend/indexer.py:68, 563-568, 957-960` | `.xlsx` supporté, **métadonnées seules** (`content=""`) | | Outils IA | `backend/tools/documents.py:66-89` + `schemas.py:296-305` | `create_xlsx` (WRITE + confirmation) — **création seule** | | Tests | `tests/test_xlsx_viewer.py` | 11 tests backend (affichage, index, save, backup, 400) | ## 2. Ce qui est supporté aujourd'hui (livré, non concerné par #153 sauf mention) **Lecture** — multi-feuilles avec onglets ; en-têtes A1/A2/B1 et numéros de ligne ; valeurs `_fmt()` (dates `YYYY-MM-DD` / `YYYY-MM-DD HH:MM`) ; lignes et colonnes de fin élaguées (`_trim`) ; feuille vide affichée ; `html.escape()` sur chaque valeur. **Édition** — `contentEditable` par ``, classe `xlsx-dirty`, bouton Save actif seulement si modification ; `Entrée` → blur, `Échap` → restauration, collage forcé en monoligne ; un `PUT` par feuille sale ; coercion automatique des nombres (`"250"` → int `250`) ; chaîne vide → cellule vidée ; backup `.bak` avant écriture ; garde-fou vault read-only (403) ; `resolve_safe_path()` (anti path-traversal) ; `check_vault_access()` + `require_auth` ; journalisation d'audit (`log_file_save`). **Divers** — téléchargement de l'original ; refresh de l'arborescence via le watcher après écriture ; rafraîchissement de la visionneuse après une action IA (`create_xlsx` → `obsigate:file-written`, BUG-076). ## 3. Limites connues (par couche) ### 3.1 Fidélité du round-trip — risque n°1 `load_workbook()` → `wb.save()` : ce qui est **réellement** perdu a été mesuré sur openpyxl 3.1.5 (2026-09-27), et non repris de la documentation : | Élément | Round-trip openpyxl 3.1.5 | |---|---| | Graphiques, images, dessins | ✅ **préservés** (mesuré : `xl/charts/`, `xl/drawings/`, `xl/media/` intacts) | | Tableaux croisés (pivot) + caches | ✅ **préservés** (`reader/excel.py` relit les `TableDefinition`, `workbook/_writer.py` les réécrit) | | Styles, formats, fusions, validation de données, mise en forme conditionnelle, commentaires | ✅ préservés | | **Valeurs calculées en cache** (`……`) | ❌ **perdues** → tout lecteur `data_only=True` (pandas, script tiers, convertisseur) renvoie `None` tant qu'Excel n'a pas recalculé | | Slicers / chronologies, contrôles de formulaire (`ctrlProps`/`activeX`), connexions & requêtes, custom XML, signature numérique, commentaires enrichis, macros | ❌ **perdus** (parties absentes de l'archive après écriture) | La liste fait foi dans le code : [`LOSSY_PARTS`](../backend/xlsx_reader.py) + la sonde `…[^<]` pour les valeurs en cache (openpyxl écrivant lui-même un `` vide). **Ce qui reste ouvert** (non mesuré, prudence) : types de graphiques exotiques (treemap, sunburst, funnel…), `sparklines`, `xl/queryTables` en lecture Excel. Un classeur qui en contient peut sortir dégradé, voire échouer au chargement — d'où le refus par défaut (A1). ### 3.2 Lecture - Aucun style, format de nombre, devise, pourcentage, largeur de colonne, ligne figée, cellule fusionnée, commentaire, lien hypertexte, validation de données, mise en forme conditionnelle. - Plafonds durs `MAX_ROWS = 500`, `MAX_COLS = 40` par feuille, **sans indicateur dans l'UI** : au-delà, contenu silencieusement tronqué et **non éditable**. - Pas de pagination ni de chargement à la demande : toutes les feuilles sont rendues d'un bloc dans le JSON (20 feuilles × 20 000 cellules = payload énorme, UI gelée). - Formules affichées **en texte** (`=B1*2`), jamais recalculées ; après édition, les cellules dépendantes ne se mettent pas à jour à l'écran. ### 3.3 UI (`viewer.js`) Navigation clavier (Tab/flèches) absente ; pas de barre de formule, pas de nom de cellule actif, pas d'undo/redo global, pas de recherche dans la feuille, pas de tri/filtre, pas d'export CSV, pas d'ajout/renommage/suppression de feuille, pas d'insertion/suppression de ligne ou colonne, pas de sélection de plage, pas de copie d'une plage, pas de retour ligne dans une cellule (`Maj+Entrée`) ; seul le retour de l'API est signalé (plafond 500 cellules) ; seule la **colonne A** est `sticky` (le `thead` ne l'est pas → les en-têtes de colonnes disparaissent au défilement vertical). **Couverture de test** : `tests/frontend/xlsx-viewer.test.mjs` (10) et `tests/e2e/xlsx-viewer.spec.js` (3) depuis #153 P0 — la navigation clavier et la barre de formule restent à faire (A7). ### 3.4 Recherche, IA et knowledge base - **Indexation** : `content=""` → un `.xlsx` est totalement **invisible** à la recherche TF-IDF, à la recherche sémantique, au remplacement global, aux tags et aux statistiques de contenu. - **Outils IA** : seul `create_xlsx` existe (crée un fichier neuf, une seule feuille, `overwrite=True` par défaut) ; `read_file` fait un `read_text()` sur l'archive ZIP → **bruit binaire** envoyé au LLM ; pas de `update_xlsx_cells` pourtant le service existe déjà, pas d'ajout de lignes, pas de `xlsx → markdown` pour le contexte. ## 4. Risques de sécurité / robustesse | # | Risque | Où | Traitement | État | |---|---|---|---|---| | R1 | Perte silencieuse (valeurs calculées, slicers, contrôles, connexions, custom XML, signature) | `mutations.edit_xlsx_cells` | **A1** — bandeau + **409** `xlsx_lossy_content` sans `force` | 🟢 livré (BUG-085) | | R2 | Écriture non atomique (`wb.save()` en place) → classeur corrompu si crash | `mutations.edit_xlsx_cells` | **A2** — `.tmp` + `os.replace` | 🟢 livré (BUG-086) | | R3 | Concurrence : deux éditions (onglets, watcher + IA) → dernier écrivain gagne | `mutations.edit_xlsx_cells` | **A3** — verrou par chemin, **409** `conflict` | 🟢 livré (BUG-087) | | R4 | **Injection de formule** : une saisie `=cmd\|…`, `=HYPERLINK(…)` est stockée comme formule par openpyxl → DDE à l'ouverture dans Excel | `mutations._write_cell` | **A4** — forçage texte (`data_type="s"`), opt-in `allow_formula` | 🟢 livré (BUG-088) | | R5 | Troncature silencieuse au-delà de 500×40 | `xlsx_reader.MAX_ROWS/MAX_COLS` | A8 / A9 | ⚪ à faire | ## 5. Backlog #153 — sous-tâches Légende : 🔴 P0 (sécurité / perte de données) · 🟡 P1 (valeur immédiate) · 🟢 P2 (confort / couverture) · effort en jours-homme de développement + tests. ### P0 — Garde-fous d'écriture (2-3 j) — 🟢 livré le 2026-09-27 - [x] **A1 — Alerte de fidélité avant écriture (R1).** `inspect_workbook()` liste ce qu'un round-trip perd (`LOSSY_PARTS` + sonde valeurs en cache) ; la lecture renvoie `xlsx_lossy_features` ; la visionneuse affiche un bandeau listant les éléments ; `PUT …/xlsx/save` répond **409** `xlsx_lossy_content` (avec `details.features`) tant que `force` n'est pas passé, le client demande confirmation puis réémet avec `force: true` (une seule fois par session). *Vérifié :* `TestXlsxLossyGuard` (5), `xlsx-viewer.test.mjs` (10), E2E (3). - [x] **A2 — Écriture atomique (R2).** `wb.save(..tmp)` puis `os.replace()` ; `.tmp` supprimé sur échec ; backup `.bak` inchangé. Le `.tmp` est ignoré par le watcher. *Vérifié :* `TestXlsxAtomicWrite` (2) — les octets d'origine sont intacts après un `save` en échec. - [x] **A3 — Verrou par fichier (R3).** Verrou `threading.Lock` par chemin (registre + garde, timeout 15 s) autour du cycle load → edit → replace ; **409** `conflict` si le délai est dépassé. L'endpoint est passé en `def` (sync) pour que l'attente s'exécute dans le threadpool. *Vérifié :* `TestXlsxWriteLock` (2). *Limite :* verrou en mémoire, par processus (suffisant pour un serveur ObsiGate, y compris desktop). - [x] **A4 — Neutralisation de l'injection de formule (R4).** `cell.data_type = "s"` après affectation : une saisie `=`/`@` est stockée en texte. Opt-in `allow_formula: true` côté API et bouton `f(x)` dans la visionneuse (état de session, jamais persisté). `+`/`-` restent des nombres. Au passage : le handler `ServiceError` expose `code` + `details` et `api()` les propage sur l'Error. *Vérifié :* `TestXlsxFormulaGuard` (4) + test du toggle côté UI. ### P1 — Recherche, IA, UX (4-6 j) - [ ] **A5 — Indexation du contenu des feuilles.** Extraire un texte (noms de feuilles + en-têtes + N premières lignes, plafond ~5 k caractères) pour le TF-IDF et la recherche sémantique, tout en gardant la lecture binaire pour l'affichage ; `content_preview` renseigné ; exclusion si le classeur est chiffré/corrompu. *Critère :* une cellule contenant un mot-clé rend le fichier trouvable ; `test_xlsx_indexing` étendu. - [ ] **A6 — Outils IA sur classeur.** `update_xlsx_cells` (enveloppe du service existant), `append_xlsx_rows`, `xlsx_to_markdown` (contexte LLM, plafonné), `list_xlsx_sheets` — risque WRITE + confirmation pour les mutations, libellés i18n dans `backend/tools/labels.py`, refresh viewer via `obsigate:file-written`. - [ ] **A7 — Navigation clavier & barre de formule.** `Tab`/`Maj+Tab`/`Entrée`/flèches, cellule active affichée (nom A1), `Maj+Entrée` pour le multiligne, copier une plage, focus visible et compatible mobile (≥ 44 px, `tests/e2e/mobile-editor.spec.js`). - [ ] **A8 — `thead` sticky + indicateur de troncature (R5).** Ligne d'en-têtes figlée au défilement vertical ; bandeau « feuille tronquée à 500 lignes × 40 colonnes » ; libellés FR/EN. - [ ] **A9 — Chargement paresseux par feuille (supprime le plafond).** Endpoint `GET /api/file/{vault}/xlsx/sheet?sheet=N&offset=&limit=` (`response_model` + `backend/openapi_docs.py`), rendu à la demande avec défilement virtuel, bouton « charger tout ». - [ ] **A10 — Types et formats de saisie.** Coercion symétrique à l'écriture/à l'affichage (nombre vs texte, booléens `TRUE`/`FALSE`, dates localisées FR — le TODO existe déjà dans `_coerce_xlsx_value`) ; affichage du type d'origine dans l'info-bulle de cellule. - [ ] **A11 — Tests frontend + E2E.** `tests/frontend/xlsx-viewer.test.mjs` (dirty, Échap, collage, 1 PUT par feuille, bouton désactivé) et `tests/e2e/xlsx-viewer.spec.js` (ouverture, onglets, édition, sauvegarde, rechargement) ; intégration au CI. - [ ] **A12 — Valeurs calculées.** Afficher la valeur en cache (2ᵉ ligne discrète) quand elle existe, via une lecture `data_only=True` de la même page d'onglets ; mention FR/EN « valeur recalculée par Excel ». ### P2 — Étendu (2-4 j) - [ ] **A13 — Tri / filtre / recherche dans la feuille + export CSV de la sélection.** - [ ] **A14 — CRUD de feuilles et de lignes/colonnes** (renommer, insérer, supprimer, dupliquer). - [ ] **A15 — Styles minimaux en écriture et lecture fidèle** (gras, fond, format devise/pourcentage/date, cellules fusionnées, volets figés) ; conserver `csv-table` comme socle de rendu. - [ ] **A16 — Formats additionnels.** `.xlsm` (`keep_vba=True`), `.xls`, `.ods`, `.csv` éditable comme tableur — dépendances à qualifier (`xlrd`/`odfpy`) ou conversion. - [ ] **A17 — Vue « tableau de bord ».** Détection des plages nommées, TCD et graphiques ; vue résumée (KPI par feuille) et proposal d'actions IA sur ces plages. ## 6. Règles de livraison (rappel `AGENTS.md` / `DELIVERY_WORKFLOW.md`) - Chaque sous-tâche démarre par un **ID stable** : nouvelle feature = `#153-A` dans la Roadmap ; si la sous-tâche est un **défaut** (A1, A2, A3, A4, A8), l'ouvrir aussi comme `BUG-NNN` dans `docs/ISSUES_TODOLIST.md` au moment du démarrage. - Backend : docstrings, `response_model` pour tout endpoint ajouté, exemple dans `backend/openapi_docs.py`, chemin utilisateur via `resolve_safe_path()`. - Frontend : vanilla JS sans build, `safeCreateIcons()`, **variables CSS** (jamais de couleur hardcodée), **i18n FR + EN** pour chaque nouveau texte (`test_i18n_parity.py` vert). - Tests : `pytest tests/test_xlsx_viewer.py`, `ruff`, `mypy`, `validate-imports`, suite frontend ciblée, E2E si l'UI change — puis CI verte. - Documentation : `CHANGELOG.md` `[Unreleased]`, Roadmap (case cochée), cette fiche (résultat), guide utilisateur i18n + README si impact utilisateur. ## 7. Historique | Date | Événement | |---|---| | 2.27.0 | #152 livré : affichage multi-feuilles, édition des cellules, téléchargement (`docs/archive/COMPLETED_v1-v2.md`) | | 2026-09-27 | Audit complet → création de #153 : limites, risques R1-R5, backlog A1-A17 | | 2026-09-27 | Périmètre de perte **remesuré** sur openpyxl 3.1.5 : graphiques / images / TCD sont préservés, seules les valeurs en cache et quelques parties exotiques sont perdues | | 2026-09-27 | **P0 livré** (BUG-085 → BUG-088) : `xlsx_lossy_features` + 409 `xlsx_lossy_content`, écriture atomique, verrou par fichier, formules stockées en texte par défaut |