feat: troncature annoncee et lecture par fenetres des tableurs #153
CI / lint (push) Successful in 2m29s
CI / security (push) Failing after 1m39s
CI / test (push) Successful in 4m1s
CI / build (push) Successful in 1m37s
CI / e2e (push) Successful in 14m47s

- BUG-090 (#153 A8) : render_sheets() expose total_rows/total_cols,
  max_rows/max_cols et truncated ; la visionneuse affiche un bandeau
  « Feuille tronquée » (i18n FR/EN) au lieu de couper en silence, et la
  ligne d'en-têtes devient sticky (top:auto sur les numéros de ligne).
- #153 A9 : GET /api/file/{vault}/xlsx/sheet?sheet&offset&limit sert une
  fenêtre de 1 à 1000 lignes avec les vraies coordonnées A1, has_more de
  pagination et valeurs calculées A12 ; 404 feuille inconnue, 415 non-xlsx.
- Tests : TestXlsxTruncationNotice (4) + TestXlsxSheetWindow (11) avec
  contre-preuves, xlsx-viewer.test.mjs 14/14, E2E 7/7 (fixture
  sample-xlsx-large.xlsx 520 lignes), suite 1417 passed / 6 skipped,
  ruff/mypy 0, i18n parity.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
2026-09-28 10:38:24 -04:00
parent 06f8e63d06
commit 6ba04c4381
24 changed files with 726 additions and 47 deletions
+145 -8
View File
@@ -28,6 +28,12 @@ logger = logging.getLogger("obsigate.xlsx_reader")
MAX_ROWS = 500
MAX_COLS = 40
# #153 A9 — window size served by ``read_sheet_window()`` (lazy per-sheet
# loading). The endpoint is bounded so a single request can never ask for the
# whole workbook back in one JSON payload; the UI pages through the rest.
MAX_WINDOW_ROWS = 1_000
DEFAULT_WINDOW_ROWS = 200
# #153 A1 — workbook parts openpyxl does not re-serialize on load+save.
# Verified against openpyxl 3.1.5: charts, images, drawings and pivot tables
# DO survive the round-trip, so they are deliberately absent from this map.
@@ -98,7 +104,11 @@ def _cell_cached(cached: list[list[str]] | None, r: int, c: int) -> str:
return row[c] if c < len(row) else ""
def _table(grid: list[list[str]], cached: list[list[str]] | None = None) -> str:
def _table(
grid: list[list[str]],
cached: list[list[str]] | None = None,
row_offset: int = 0,
) -> str:
"""Render a grid as an HTML table.
``cached`` is the same grid read with ``data_only=True`` (#153 A12): where a
@@ -107,6 +117,10 @@ def _table(grid: list[list[str]], cached: list[list[str]] | None = None) -> str:
number Excel last calculated instead of only the formula text. The span
carries ``data-cached-value`` and is titled client-side from
``xlsx.cached_value_title`` — the backend never emits UI text.
``row_offset`` is the number of rows skipped before this grid (#153 A9): the
row numbers and the ``data-cell`` references must stay the real A1
coordinates of the sheet, not of the window.
"""
if not grid:
return "<p><em>Feuille vide</em></p>"
@@ -119,7 +133,7 @@ def _table(grid: list[list[str]], cached: list[list[str]] | None = None) -> str:
]
out += [f"<th>{get_column_letter(c)}</th>" for c in range(1, n_cols + 1)]
out.append("</tr></thead><tbody>")
for r, row in enumerate(grid, start=1):
for r, row in enumerate(grid, start=row_offset + 1):
out.append(f'<tr><th class="xlsx-rownum">{r}</th>')
for c, val in enumerate(row, start=1):
ref = f"{get_column_letter(c)}{r}"
@@ -191,19 +205,24 @@ def inspect_workbook(file_path: Path) -> list[str]:
return []
def render_sheets(file_path: Path) -> list[dict[str, str]]:
"""Return ``[{"name": sheet_title, "html": table_html}, ...]``.
def render_sheets(file_path: Path) -> list[dict[str, Any]]:
"""Return one dict per sheet: ``{name, html, rows, cols, total_*, truncated}``.
Reads the workbook twice: once with ``data_only=False`` for the formulas
(what the user must edit) and, when any formula carries a cached result
(#153 A12), once with ``data_only=True`` to show what Excel last computed.
The second pass is skipped entirely when the archive holds no cached value,
so the common case still costs a single load.
``total_rows``/``total_cols`` are the dimensions the sheet declares and
``truncated`` says whether the hard caps actually cut it (#153 A8) — the
viewer needs both to stop silently hiding the tail of a sheet.
"""
wb = load_workbook(str(file_path), read_only=True, data_only=False)
try:
formulas = [_sheet_grid(ws) for ws in wb.worksheets]
titles = [ws.title for ws in wb.worksheets]
extents = [_sheet_extent(ws) for ws in wb.worksheets]
finally:
wb.close()
@@ -219,16 +238,134 @@ def render_sheets(file_path: Path) -> list[dict[str, str]]:
# shift every cached value left of its formula. Indexing it
# positionally against the untrimmed grid keeps the two aligned.
shadow = cached[i] if cached is not None and i < len(cached) else None
sheets.append({"name": title, "html": _table(grid, shadow)})
total_rows, total_cols = extents[i]
sheets.append(
{
"name": title,
"html": _table(grid, shadow),
"rows": len(grid),
"cols": max((len(r) for r in grid), default=0),
"total_rows": total_rows,
"total_cols": total_cols,
# Coverage, not display size: `rows`/`cols` are post-trim (a
# sheet of 3 filled cells in a 500-row block renders 1x1), and
# the client must announce the cap it stopped at, not how many
# cells happen to be non-empty.
"max_rows": MAX_ROWS,
"max_cols": MAX_COLS,
# A sheet is truncated when the caps, not the trailing blanks,
# decided its shape: comparing against the *rendered* size would
# flag every sheet carrying a few empty formatted rows.
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
}
)
return sheets
def _sheet_grid(ws: Any) -> list[list[str]]:
"""Read one worksheet into a grid of formatted strings, bounded by the caps."""
def read_sheet_window(
file_path: Path,
sheet: str,
offset: int = 0,
limit: int = DEFAULT_WINDOW_ROWS,
) -> dict[str, Any] | None:
"""Return a window of rows of one sheet, or ``None`` if the sheet is unknown.
Backs the lazy per-sheet loading of #153 A9: the viewer asks for the rows
it is about to display instead of shipping every sheet in the initial file
payload. ``offset`` is 0-based; the row numbers and the ``data-cell``
references in the returned ``html`` are the real A1 coordinates of the
sheet, so a window is indistinguishable from a full render.
``limit`` is clamped to :data:`MAX_WINDOW_ROWS`. Raises nothing: an unknown
sheet yields ``None`` and a broken workbook propagates the caller's usual
500.
"""
offset = max(int(offset), 0)
limit = min(max(int(limit), 1), MAX_WINDOW_ROWS)
wb = load_workbook(str(file_path), read_only=True, data_only=False)
try:
if sheet not in wb.sheetnames:
return None
ws = wb[sheet]
total_rows, total_cols = _sheet_extent(ws)
grid = _trim(
_sheet_grid(ws, min_row=offset + 1, max_row=offset + limit)
)
finally:
wb.close()
shadow: list[list[str]] | None = None
# Same A12 rule as the full render: the second read only happens when the
# archive really holds cached results.
if _has_cached_values(file_path):
shadow = _read_cached_window(file_path, sheet, offset, limit)
return {
"sheet": sheet,
"offset": offset,
"limit": limit,
"rows": len(grid),
"cols": max((len(r) for r in grid), default=0),
"total_rows": total_rows,
"total_cols": total_cols,
"max_rows": MAX_ROWS,
"max_cols": MAX_COLS,
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
"has_more": offset + len(grid) < total_rows,
"html": _table(grid, shadow, row_offset=offset),
}
def _read_cached_window(
file_path: Path, sheet: str, offset: int, limit: int
) -> list[list[str]] | None:
"""``data_only=True`` grid for one window, or ``None`` if unavailable.
Best effort like :func:`_read_cached_grids`: a workbook Excel opens but
openpyxl cannot re-read must still display (formulas only).
"""
try:
wb = load_workbook(str(file_path), read_only=True, data_only=True)
except Exception:
return None
try:
if sheet not in wb.sheetnames:
return None
return _sheet_grid(
wb[sheet], min_row=offset + 1, max_row=offset + limit
)
except Exception:
logger.debug("xlsx cached window unavailable", exc_info=True)
return None
finally:
wb.close()
def _sheet_extent(ws: Any) -> tuple[int, int]:
"""Rows and columns the worksheet declares, never negative.
``max_row``/``max_column`` come from the sheet's dimension record; a
hand-edited file may omit it, hence the defensive coercion.
"""
try:
rows = max(int(getattr(ws, "max_row", 0) or 0), 0)
except (TypeError, ValueError):
rows = 0
try:
cols = max(int(getattr(ws, "max_column", 0) or 0), 0)
except (TypeError, ValueError):
cols = 0
return rows, cols
def _sheet_grid(
ws: Any, min_row: int = 1, max_row: int = MAX_ROWS, max_col: int = MAX_COLS
) -> list[list[str]]:
"""Read a worksheet window into a grid of formatted strings, bounded by the caps."""
return [
[_fmt(v) for v in row]
for row in ws.iter_rows(
min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True
min_row=min_row, max_row=max_row, max_col=max_col, values_only=True
)
]