feat: éditeur tableur — grille A → Z par blocs, barre de menus Sheets, panneau de couleurs, création .xlsx (#180 → #186)
CI / lint (push) Successful in 2m43s
CI / security (push) Successful in 1m33s
CI / test (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s

Restaure et termine le lot tableur resté non committé. Il n'existait que dans un
`git stash` (24 fichiers suivis) et en fichiers non suivis (menus.js, formula.js,
color-picker.js, tests, fiches) : la 2.53.3 livrée ne le contenait donc pas. Le
stash, créé avant le commit BUG-108, n'a jamais été restauré.

#180 Redimensionnement au curseur (en-tête de bord miroité + classe de repaint),
grille thémée sur les 15 thèmes et les modes contraste élevé / sépia, menu
Fichier au niveau des onglets, barre épinglée pleine largeur.

#181 Modèle de saisie Google Sheets : sélection ≠ édition (caret masqué sans
quitter contenteditable, double-clic / F2 / première frappe qui remplace,
Entrée contextuelle, Échap qui restaure) + inventaire priorisé des écarts.

#182 Désélection fiable (clic simple sans dépendre du focus), contours de plage
non empilés (sélecteur de classes sans point), couleurs texte/fond sur une
plage, clic droit qui préserve la sélection multiple.

#183 Panneau de couleurs façon Google Sheets : palette 8 × 10, STANDARD,
PERSONNALISÉ, coche selon la luminance, sortie `#rrggbb` (une valeur HSL était
rejetée par normHex).

#184 Peinture de format complète (toutes propriétés, source sans format =
réinitialisation), sélection multi-lignes/colonnes depuis les marges, grille
étendue : colonnes A → Z d'emblée, lignes ajoutées PAR BLOCS DE 100 au
défilement jusqu'à 1000 — matérialiser 1000 lignes d'un coup = ~26 000 cellules
câblées par feuille, ce qui épuisait le tas de la suite JSDOM ; index de
cellules `ref → td`, court-circuits formule/styles, marqueur data-wired.

#185 Barre de menus : les 10 menus Google Sheets (161 entrées, 125 câblées, 36
annoncées indisponibles), ruban façon Sheets, grille unie 1 px dérivée du thème.

#186 « Créer un fichier » propose .xlsx et construit un vrai classeur OPC
(openpyxl) au lieu d'une charge utile texte illisible.

Corrections trouvées en restaurant et en exerçant le lot :
- fuite mémoire : écouteur `click` anonyme posé sur #content-area à chaque
  rendu, jamais retiré — sa fermeture retenait la grille précédente en entier ;
- sorties Markdown / HTML / Imprimer de la barre de menus inertes
  (`data-xlsx-export` jamais réparti, seul `data-xlsx-action` l'était) ;
- collision de classe `.xlsx-structure-menu` entre la barre de menus et le menu
  Structure de la barre d'outils (toute requête tombait sur un nœud masqué) ;
- curseur col/row-resize absent quand le pointeur est sur la table elle-même ;
- l'export emportait les lignes et colonnes vides du quadrillage (CSV, Markdown,
  HTML, impression) ;
- clic extérieur avalé par la grâce de 250 ms du menu contextuel (destinée au
  seul appui long tactile) ;
- une entrée indisponible laissait la barre de menus ouverte.

Tests : pytest 1595 passés / 2 ignorés ; ruff et mypy 0 erreur ; bandit 0 ;
33 suites frontend vertes (xlsx-viewer 163/163, xlsx-menus 19/19,
xlsx-formula 14/14) ; E2E complet 132 passés / 12 ignorés ; E2E xlsx-viewer
19/19.
This commit is contained in:
2026-10-08 12:00:15 -04:00
parent ce1944d437
commit 634ba8a272
39 changed files with 9996 additions and 491 deletions
+109
View File
@@ -250,6 +250,115 @@ class TestStyleWriting:
assert backups, "the rewrite is backed up first"
class TestExtendedStyles:
"""Nouvelles clés de style (barre façon Sheets) : taille, barré,
alignements verticaux, retour ligne, rotation, bordures — et commentaires."""
def _style(self, client, path, ops, **extra):
return client.put(
f"/api/file/{VAULT}/xlsx/style",
params={"path": path},
json={"ops": ops, **extra},
)
def test_font_size_strike_valign_wrap_rotation(self, client, plain_book):
resp = self._style(client, "plain.xlsx", [
{
"op": "cell", "sheet": "Data", "range": "A1",
"style": {
"font_size": 14, "strike": True, "valign": "middle",
"wrap": True, "rotation": -45,
},
},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(plain_book)
cell = wb["Data"]["A1"]
assert cell.font.size == 14
assert cell.font.strike is True
assert cell.alignment.vertical == "center"
assert cell.alignment.wrap_text is True
assert cell.alignment.textRotation == 135
wb.close()
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
sheet = resp.json()["xlsx_sheets"][0]
assert "font-size:14pt" in sheet["styles"]["A1"]
assert "line-through" in sheet["styles"]["A1"]
assert "vertical-align:middle" in sheet["styles"]["A1"]
assert "white-space:normal" in sheet["styles"]["A1"]
# La diagonale (-45° → 135 OOXML) est relue en `rotate()` CSS.
assert "transform:rotate(45deg)" in sheet["styles"]["A1"]
def test_borders_all_outer_none(self, client, plain_book):
resp = self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1:B2",
"style": {"border": "all", "border_color": "#112233"}},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(plain_book)
side = wb["Data"]["A1"].border.left
assert side.style == "thin" and side.color.rgb == "FF112233"
wb.close()
resp = self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1:B2", "style": {"border": "none"}},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(plain_book)
cleared = wb["Data"]["A1"].border.left
assert cleared is None or cleared.style is None
wb.close()
def test_comment_set_clear_and_readback(self, client, plain_book):
resp = self._style(client, "plain.xlsx", [
{"op": "comment_set", "sheet": "Data", "ref": "B2", "text": "À vérifier"},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(plain_book)
assert wb["Data"]["B2"].comment.text == "À vérifier"
wb.close()
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
assert resp.json()["xlsx_sheets"][0]["comments"]["B2"] == "À vérifier"
resp = self._style(client, "plain.xlsx", [
{"op": "comment_clear", "sheet": "Data", "ref": "B2"},
])
assert resp.status_code == 200
wb = openpyxl.load_workbook(plain_book)
assert wb["Data"]["B2"].comment is None
wb.close()
def test_formats_map_lists_number_formats(self, client, plain_book):
self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "B2", "style": {"number_format": "0.00"}},
])
resp = client.get(f"/api/file/{VAULT}", params={"path": "plain.xlsx"})
assert resp.json()["xlsx_sheets"][0]["formats"]["B2"] == "0.00"
def test_extended_invalid_inputs_are_refused(self, client, plain_book):
style = lambda s: self._style(client, "plain.xlsx", [
{"op": "cell", "sheet": "Data", "range": "A1", "style": s},
]).status_code
assert style({"font_size": 200}) == 400
assert style({"font_size": "grand"}) == 400
assert style({"valign": "diagonal"}) == 400
# 180° (texte vertical retourné, relu tel quel) est accepté — seules
# les valeurs vraiment hors bornes sont refusées.
assert style({"rotation": 180}) == 200
assert style({"rotation": 181}) == 400
assert style({"rotation": -91}) == 400
assert style({"border": "dotted-circle"}) == 400
assert style({"border_style": "wavy"}) == 400
assert self._style(client, "plain.xlsx", [
{"op": "comment_set", "sheet": "Data", "ref": "B2", "text": ""},
]).status_code == 400
assert self._style(client, "plain.xlsx", [
{"op": "comment_set", "sheet": "Data", "ref": "B2:C3", "text": "x"},
]).status_code == 400
class TestEndpointWiring:
def test_read_response_carries_styles_merges_freeze(self, client, styled_book):
resp = client.get(f"/api/file/{VAULT}", params={"path": "styles.xlsx"})