feat: completude de l'editeur Excel - mise en forme, sortie, concurrence, cache, outils IA #156
CI / lint (push) Successful in 2m36s
CI / security (push) Successful in 1m49s
CI / test (push) Successful in 4m33s
CI / build (push) Successful in 2m39s
CI / e2e (push) Successful in 15m45s

L'editeur lisait les styles mais ne les ecrivait pas, exportait la feuille
entiere et laissait le dernier-ecrivain gagner entre processus.

- A8 : bouton Mise en forme (gras/italique/souligne, alignements, couleurs via
  selecteur natif, formats de nombre, fusion, volets figes, largeur/hauteur) et
  nouvelle route PUT .../xlsx/style (verrou, backup, swap atomique, garde de
  perte, If-Match) ; A9 : decision "pas de moteur de formule" annoncee dans
  l'UI ; A10 : undo/redo unifie, piles par fichier conservees au re-rendu
- A11 : export de la selection + Markdown/HTML/impression et recherche sur
  toutes les feuilles ; A12 : concurrence optimiste (If-Match -> 409 reparable,
  retry qui relit) ; A13 : cache des metadonnees par (chemin, mtime, taille)
- A14 : outils IA .xlsm/.csv + search_workbook, analyze_range,
  edit_xlsx_structure
- tests : test_xlsx_styles.py (17), test_spreadsheet_tools.py (34),
  TestOptimisticConcurrency/TestMetaCache, JSDOM xlsx-viewer 107/107
This commit is contained in:
2026-09-30 07:04:47 -04:00
parent 1bacfd69d9
commit c5c225a68e
32 changed files with 4615 additions and 302 deletions
+146 -14
View File
@@ -14,9 +14,11 @@ read-only; :func:`render_csv_table` turns a CSV into the same table shape.
from __future__ import annotations
import csv
import html
import logging
import re
import threading
import zipfile
from datetime import date, datetime
from pathlib import Path
@@ -65,8 +67,22 @@ LOSSY_PARTS: dict[str, tuple[str, ...]] = {
# Excel recalculates.
_CACHED_FORMULA_RE = re.compile(rb"<f[ >][^<]*</f>\s*<v>[^<]")
# Sheet XML scanned by the cached-formula probe (CPU guard, like MAX_REPLACE_FILE_BYTES).
_MAX_PROBE_BYTES = 8_000_000
# Sheet XML scanned by the cached-formula probe (CPU guard, like
# MAX_REPLACE_FILE_BYTES). BUG-099 — the allowance is PER SHEET: a single huge
# first sheet used to eat the whole budget and hide a cached formula sitting in
# the next one. The total ceiling still bounds the work on a many-sheet archive.
# Running out of budget is reported as *unverified* (never as "nothing to lose")
# so the write guard stays cautious instead of silently dropping the values.
_MAX_PROBE_BYTES_PER_SHEET = 4_000_000
_MAX_PROBE_BYTES_TOTAL = 32_000_000
# #156-A13 — metadata cache. `read_sheet_window()` serves one window at a time
# and used to reload the whole workbook (normal mode, data_only=False) for every
# window, just to read the style/merge/freeze maps of one sheet. The result is
# keyed by (path, mtime_ns, size): any write replaces the file, hence the key.
_META_CACHE_MAX = 8
_meta_cache: dict[str, tuple[tuple[int, int], dict[str, dict[str, Any]]]] = {}
_meta_cache_lock = threading.Lock()
# #153 A17 — OPC parts of chart / pivot objects, matched against the archive
# name list (xl/charts/chart1.xml, xl/pivotTables/pivotTable1.xml, …).
@@ -111,6 +127,10 @@ def _cell_fragments(cell: Any) -> tuple[list[str], str | None]:
fragments.append("font-weight:600")
if font and font.italic:
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).
if font and font.underline:
fragments.append("text-decoration:underline")
fmt = cell.number_format
if fmt and fmt not in ("General", "@"):
# A custom number format is signalled typographically (mono font)
@@ -320,24 +340,65 @@ def _table(
return "".join(out)
def _has_cached_formulas(zf: zipfile.ZipFile) -> bool:
"""True when at least one formula cell still carries its computed value."""
budget = _MAX_PROBE_BYTES
def _is_sheet_xml(name: str) -> bool:
"""True for the worksheet XML parts the cached-formula probe scans."""
return name.startswith("xl/worksheets/sheet") and name.endswith(".xml")
def _scan_cached_formulas(zf: zipfile.ZipFile) -> tuple[bool, bool]:
"""Scan the sheet XML for a formula carrying a non-empty cached result.
Returns ``(found, unverified)``. ``unverified`` is True when the byte
budget stopped the scan before every sheet could be read to the end: a
negative result is then **not** proof that the workbook holds no cached
value (BUG-099), so callers must not treat it as a licence to write.
Each sheet gets its own :data:`_MAX_PROBE_BYTES_PER_SHEET` allowance (a
single huge sheet can no longer starve the others) while
:data:`_MAX_PROBE_BYTES_TOTAL` bounds the whole archive. A hit short-
circuits the scan: the answer is already known.
"""
budget = _MAX_PROBE_BYTES_TOTAL
unverified = False
for name in zf.namelist():
if not name.startswith("xl/worksheets/sheet") or not name.endswith(".xml"):
if not _is_sheet_xml(name):
continue
sheet_budget = min(_MAX_PROBE_BYTES_PER_SHEET, budget)
exhausted = False
try:
with zf.open(name) as fh:
while budget > 0:
chunk = fh.read(65536)
while sheet_budget > 0:
# Read at most what the sheet's allowance has left, so one
# large chunk can never consume the whole total budget.
chunk = fh.read(min(65536, sheet_budget))
if not chunk:
break
break # read to the end: this sheet is verified clean
sheet_budget -= len(chunk)
budget -= len(chunk)
if _CACHED_FORMULA_RE.search(chunk):
return True
return True, False
else:
# Left the loop on the budget, not on EOF.
exhausted = True
except (KeyError, OSError, zipfile.BadZipFile):
continue
return False
if exhausted:
unverified = True
if budget <= 0:
unverified = True
break
return False, unverified
def _has_cached_formulas(zf: zipfile.ZipFile) -> bool:
"""True when at least one formula cell still carries its computed value.
Boolean view of :func:`_scan_cached_formulas` for the display path (the
second ``data_only=True`` read): a truncated scan simply skips the shadow
grid, it never claims the workbook is lossless.
"""
found, _ = _scan_cached_formulas(zf)
return found
def inspect_workbook(file_path: Path) -> list[str]:
@@ -349,6 +410,10 @@ def inspect_workbook(file_path: Path) -> list[str]:
``cached_values`` is a synthetic key: openpyxl keeps the formula but drops
the cached result, so the workbook stays correct once Excel recalculates it.
``cached_values_unverified`` (BUG-099) is the other synthetic key: the
cached-value probe ran out of budget, so a negative result is not proof —
the entry keeps the write guard cautious (409 + confirmation) rather than
promising a lossless round-trip it cannot vouch for.
"""
try:
with zipfile.ZipFile(file_path) as zf:
@@ -358,13 +423,27 @@ def inspect_workbook(file_path: Path) -> list[str]:
for key, prefixes in LOSSY_PARTS.items()
if any(name.startswith(prefix) for name in names for prefix in prefixes)
}
if _has_cached_formulas(zf):
cached_found, cached_unverified = _scan_cached_formulas(zf)
if cached_found:
found.add("cached_values")
elif cached_unverified:
found.add("cached_values_unverified")
return sorted(found)
except (OSError, zipfile.BadZipFile):
return []
def invalidate_workbook_meta(file_path: Path | str) -> None:
"""Drop the cached metadata of one workbook (#156-A13).
Called by the write path right after the atomic replace: the (mtime, size)
key already changes on a rewrite, this only closes the theoretical window
where a same-size write lands on the same timestamp tick.
"""
with _meta_cache_lock:
_meta_cache.pop(str(file_path), None)
def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
"""Return ``{sheet: {styles, aligns, merges, freeze}}`` for every sheet.
@@ -372,7 +451,31 @@ def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
fragments are the workbook's own values, a failure yields ``{}`` per sheet
so the viewer keeps its plain rendering. Styles are read with
``data_only=False`` — the edited value is the formula, not its result.
#156-A13 — the result is cached on ``(path, mtime_ns, size)``: a truncated
sheet is fetched window by window, and each window used to pay a full
workbook load for these maps alone. Callers only read from the mapping.
"""
try:
st = file_path.stat()
except OSError:
return {}
key = str(file_path)
stamp = (st.st_mtime_ns, st.st_size)
with _meta_cache_lock:
hit = _meta_cache.get(key)
if hit and hit[0] == stamp:
return hit[1]
out = _read_workbook_meta_uncached(file_path)
with _meta_cache_lock:
_meta_cache[key] = (stamp, out)
while len(_meta_cache) > _META_CACHE_MAX:
_meta_cache.pop(next(iter(_meta_cache)))
return out
def _read_workbook_meta_uncached(file_path: Path) -> dict[str, dict[str, Any]]:
"""Load the workbook once and build the per-sheet metadata maps."""
try:
wb = load_workbook(str(file_path), data_only=False)
except Exception:
@@ -781,17 +884,46 @@ def read_workbook_dashboard(file_path: Path) -> dict[str, Any]:
# ── #153 A16 — additional spreadsheet formats ───────────────────────────────
def render_csv_table(raw: str, *, delimiter: str = ",") -> str:
# BUG-098 — a CSV written by a French office suite is `;`-separated, and the
# delimiter must be *detected* (then reused on write-back), not assumed.
CSV_SNIFF_BYTES = 4096
_CSV_DELIMITERS = (",", ";", "\t", "|")
def sniff_csv_delimiter(raw: str) -> str:
"""Return the most likely field delimiter of *raw* (``,`` as fallback).
:class:`csv.Sniffer` handles quoted fields and multi-line values; it is
unreliable on short or single-column samples, so the first non-empty line
is counted as a tie-breaker and a comma remains the last resort.
"""
sample = raw[:CSV_SNIFF_BYTES]
try:
return csv.Sniffer().sniff(sample, delimiters="".join(_CSV_DELIMITERS)).delimiter
except csv.Error:
pass
first = next((line for line in sample.splitlines() if line.strip()), "")
counts = {delim: first.count(delim) for delim in _CSV_DELIMITERS}
best = max(counts, key=lambda delim: counts[delim])
return best if counts[best] else ","
def render_csv_table(raw: str, *, delimiter: str | None = None) -> str:
"""Render CSV text as the same HTML table shape the xlsx viewer consumes.
Row numbers replace the A1 column: a CSV has no fixed column count, so
the first row is a plain data row like the others (the viewer offers the
toolbar either way). Every cell is HTML-escaped at render time.
``delimiter`` defaults to the sniffed one (:func:`sniff_csv_delimiter`,
BUG-098): a `;`-separated file used to render as a single column.
"""
import csv as csv_mod
import io as io_mod
reader = csv_mod.reader(io_mod.StringIO(raw), delimiter=delimiter)
reader = csv_mod.reader(
io_mod.StringIO(raw), delimiter=delimiter or sniff_csv_delimiter(raw)
)
try:
rows = [row for row in reader]
except csv_mod.Error: