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
+139 -10
View File
@@ -129,8 +129,16 @@ def _cell_fragments(cell: Any) -> tuple[list[str], str | None]:
fragments.append("font-style:italic")
# #156-A8 — underline is written from the viewer too, so it is read back
# (the toggle in the formatting menu needs to see its own effect).
decorations = []
if font and font.underline:
fragments.append("text-decoration:underline")
decorations.append("underline")
if font and getattr(font, "strike", False):
decorations.append("line-through")
if decorations:
fragments.append("text-decoration:" + " ".join(decorations))
size = getattr(font, "size", None) if font else None
if isinstance(size, (int, float)) and size != 11:
fragments.append(f"font-size:{size:g}pt")
fmt = cell.number_format
if fmt and fmt not in ("General", "@"):
# A custom number format is signalled typographically (mono font)
@@ -140,29 +148,129 @@ def _cell_fragments(cell: Any) -> tuple[list[str], str | None]:
fragments.append("font-family:'JetBrains Mono',monospace")
alignment = cell.alignment
align = alignment.horizontal if alignment else None
return fragments, (align if align in ("left", "right", "center") else None)
if alignment is not None:
vertical = getattr(alignment, "vertical", None)
if vertical == "center":
fragments.append("vertical-align:middle")
elif vertical in ("top", "bottom"):
fragments.append(f"vertical-align:{vertical}")
if getattr(alignment, "wrap_text", None) is True:
fragments.append("white-space:normal")
elif getattr(alignment, "wrap_text", None) is False:
fragments.append("white-space:nowrap")
rotation = getattr(alignment, "text_rotation", None) or 0
if rotation == 90 or rotation == 255:
fragments.append("writing-mode:vertical-rl")
elif rotation == 180:
fragments.append("writing-mode:vertical-rl;transform:scale(-1)")
else:
# Angles diagonaux (ex. ±45° du menu Format › Rotation) : OOXML
# stocke l'anti-horaire sur 0..90 et l'horaire sur 91..180 (-45°
# → 135, cf. mutations). Le CSS tourne en sens horaire : signe
# inversé pour un rendu fidèle.
excel = rotation if rotation <= 90 else -(180 - rotation)
if excel:
fragments.append(f"transform:rotate({-excel}deg)")
border = getattr(cell, "border", None)
if border is not None:
sides = []
for side_name in ("left", "right", "top", "bottom"):
side = getattr(border, side_name, None)
style = getattr(side, "style", None)
if not style:
continue
color = getattr(getattr(side, "color", None), "rgb", None)
hexpart = ""
if isinstance(color, str) and len(color) == 8:
hexpart = f" #{color[2:].lower()}"
width = {"medium": "2px", "thick": "3px"}.get(style, "1px")
css_style = {"dashed": "dashed", "dotted": "dotted", "double": "double"}.get(
style, "solid"
)
sides.append(f"border-{side_name}:{width} {css_style}{hexpart}")
fragments.extend(sides)
return fragments, (align if align in ("left", "right", "center", "justify") else None)
def _sheet_style_maps(ws: Any) -> tuple[dict[str, str], dict[str, str]]:
"""Flat ``{ref: css}`` and ``{ref: align}`` maps of one worksheet.
def _sheet_style_maps(
ws: Any,
) -> tuple[dict[str, str], dict[str, str], dict[str, str], dict[str, str]]:
"""Flat maps of one worksheet: css, align, comment text, number format.
The flat string is what the API serves and what the viewer applies
verbatim to ``td.style``; a plain cell is simply absent from the map.
``left`` is the table default and never included. Bounded by
``MAX_ROWS x MAX_COLS`` like the render itself.
``MAX_ROWS x MAX_COLS`` like the render itself. Comment texts are capped
(tooltip use) and formats only list non-General number formats.
"""
styles: dict[str, str] = {}
aligns: dict[str, str] = {}
comments: dict[str, str] = {}
formats: dict[str, str] = {}
for row in ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS):
for cell in row:
if cell.value is None and cell.number_format == "General":
comment = getattr(cell, "comment", None)
comment_text = getattr(comment, "text", None) if comment is not None else None
# Une cellule vide mais stylée (gras, diagonale, commentaire…)
# doit survivre à la relecture : `has_style` (et le commentaire)
# sont les seuls tests rapides qui la distinguent d'une cellule
# vraiment vierge — sinon le style semble « ne pas fonctionner »
# dès qu'on recharge le classeur.
if cell.value is None and cell.number_format == "General" and not cell.has_style:
if comment_text:
comments[cell.coordinate] = str(comment_text)[:500]
continue
fragments, align = _cell_fragments(cell)
if fragments:
styles[cell.coordinate] = ";".join(fragments)
if align and align != "left":
aligns[cell.coordinate] = align
return styles, aligns
if comment_text:
comments[cell.coordinate] = str(comment_text)[:500]
fmt = getattr(cell, "number_format", None)
if fmt and fmt not in ("General", "@", None):
formats[cell.coordinate] = str(fmt)
return styles, aligns, comments, formats
def _sheet_dim_maps(ws: Any) -> tuple[dict[str, float], dict[str, float]]:
"""Explicit column widths (units) and row heights (pt), by letter/number.
Only dimensions the file sets explicitly are listed (defaults are a
rendering concern of the client). Bounded like the render itself.
"""
from openpyxl.utils import column_index_from_string
colwidths: dict[str, float] = {}
try:
for key, dim in (getattr(ws, "column_dimensions", {}) or {}).items():
width = getattr(dim, "width", None)
if not isinstance(width, (int, float)):
continue
letters = str(key).split(":")
try:
cols = [column_index_from_string(a.strip()) for a in letters]
except ValueError:
continue
lo, hi = min(cols), max(cols)
for c in range(lo, min(hi, MAX_COLS) + 1):
if c >= 1:
from openpyxl.utils import get_column_letter
colwidths[get_column_letter(c)] = float(width)
except Exception:
logger.debug("xlsx column widths unavailable", exc_info=True)
rowheights: dict[str, float] = {}
try:
for r, dim in (getattr(ws, "row_dimensions", {}) or {}).items():
if not isinstance(r, int) or not 1 <= r <= MAX_ROWS:
continue
height = getattr(dim, "height", None)
if isinstance(height, (int, float)):
rowheights[str(r)] = float(height)
except Exception:
logger.debug("xlsx row heights unavailable", exc_info=True)
return colwidths, rowheights
def read_sheet_styles(file_path: Path, sheet: str) -> dict[str, dict[str, Any]]:
@@ -445,7 +553,8 @@ def invalidate_workbook_meta(file_path: Path | str) -> None:
def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
"""Return ``{sheet: {styles, aligns, merges, freeze}}`` for every sheet.
"""Return ``{sheet: {styles, aligns, comments, formats, colwidths,
rowheights, merges, freeze}}``.
One normal (non-streaming) load serves the three A15 metadata maps: the
fragments are the workbook's own values, a failure yields ``{}`` per sheet
@@ -483,12 +592,17 @@ def _read_workbook_meta_uncached(file_path: Path) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
try:
for ws in wb.worksheets:
styles, aligns = _sheet_style_maps(ws)
styles, aligns, comments, formats = _sheet_style_maps(ws)
colwidths, rowheights = _sheet_dim_maps(ws)
merged = getattr(ws, "merged_cells", None)
ranges = getattr(merged, "ranges", None) or []
out[ws.title] = {
"styles": styles,
"aligns": aligns,
"comments": comments,
"formats": formats,
"colwidths": colwidths,
"rowheights": rowheights,
"merges": [str(r) for r in ranges],
"freeze": str(getattr(ws, "freeze_panes", None) or ""),
}
@@ -496,7 +610,14 @@ def _read_workbook_meta_uncached(file_path: Path) -> dict[str, dict[str, Any]]:
except Exception:
logger.debug("xlsx meta unavailable", exc_info=True)
for t in wb.sheetnames:
out.setdefault(t, {"styles": {}, "aligns": {}, "merges": [], "freeze": ""})
out.setdefault(
t,
{
"styles": {}, "aligns": {}, "comments": {}, "formats": {},
"colwidths": {}, "rowheights": {},
"merges": [], "freeze": "",
},
)
return out
finally:
wb.close()
@@ -565,6 +686,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
"styles": sheet_meta.get("styles", {}),
"aligns": sheet_meta.get("aligns", {}),
"comments": sheet_meta.get("comments", {}),
"formats": sheet_meta.get("formats", {}),
"colwidths": sheet_meta.get("colwidths", {}),
"rowheights": sheet_meta.get("rowheights", {}),
"merges": sheet_meta.get("merges", []),
"freeze": sheet_meta.get("freeze", ""),
}
@@ -629,6 +754,10 @@ def read_sheet_window(
"html": _table(grid, shadow, row_offset=offset, styles=meta.get("styles")),
"styles": meta.get("styles", {}),
"aligns": meta.get("aligns", {}),
"comments": meta.get("comments", {}),
"formats": meta.get("formats", {}),
"colwidths": meta.get("colwidths", {}),
"rowheights": meta.get("rowheights", {}),
"merges": meta.get("merges", []),
"freeze": meta.get("freeze", ""),
}