diff --git a/CHANGELOG.md b/CHANGELOG.md index a3a4c24..1a137f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.36.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.37.0**. --- @@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.37.0] — 2026-09-28 + +--- + ## [2.36.0] — 2026-09-28 --- diff --git a/README.fr.md b/README.fr.md index 1c79b42..7bf67f1 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.36.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.37.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.36.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.37.0). --- -*Projet : ObsiGate | Version : 2.36.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.37.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 4e6827e..84ac30a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.36.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.37.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.36.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.37.0). --- -*Project: ObsiGate | Version: 2.36.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.37.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 3a05135..c94fd31 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.36.0 +2.37.0 diff --git a/backend/routers/files_read.py b/backend/routers/files_read.py index 316aaa1..a2c9ed1 100644 --- a/backend/routers/files_read.py +++ b/backend/routers/files_read.py @@ -309,6 +309,9 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative try: from backend.xlsx_reader import inspect_workbook, render_sheets + # #153 A15 — every sheet dict already carries its styles, aligns, + # merges and freeze anchor (read_workbook_meta, one normal-mode + # load inside render_sheets). sheets = render_sheets(file_path) size = file_path.stat().st_size return { diff --git a/backend/xlsx_reader.py b/backend/xlsx_reader.py index 05cc0c2..e6114c8 100644 --- a/backend/xlsx_reader.py +++ b/backend/xlsx_reader.py @@ -65,6 +65,130 @@ MAX_INDEX_CHARS = 5_000 _INDEX_ROWS_PER_SHEET = 20 MAX_INDEX_SHEETS = 20 +# #153 A15 — reading styles is a second (non-read_only) pass on the sheet XML. +# Bounded like everything else: a cell must be INSIDE the rendered window to +# deserve an inline style, so a huge workbook never triggers a huge payload. +# Only data-driven fragments are emitted: the hex values come from the file, +# never from a hardcoded color table. + + +def _cell_fragments(cell: Any) -> tuple[list[str], str | None]: + """Inline CSS fragments of one cell plus its horizontal alignment. + + Fixed, color-first order: the API contract documents ``color:...`` as the + first fragment of a styled cell. Only data-driven values are emitted — + every hex comes from the workbook itself, never a hardcoded table. + """ + fragments: list[str] = [] + font = cell.font + if font and font.color is not None and isinstance(font.color.rgb, str): + # ARGB from the workbook itself — never a hardcoded table. + rgb = font.color.rgb + if len(rgb) == 8 and rgb != "FF000000": + fragments.append(f"color:#{rgb[2:].lower()}") + fill = cell.fill + if fill and fill.fgColor is not None and isinstance(fill.fgColor.rgb, str): + rgb = fill.fgColor.rgb + if len(rgb) == 8 and rgb not in ("00000000", "FFFFFFFF"): + fragments.append(f"background:#{rgb[2:].lower()}") + if font and font.bold: + fragments.append("font-weight:600") + if font and font.italic: + fragments.append("font-style:italic") + fmt = cell.number_format + if fmt and fmt not in ("General", "@"): + # A custom number format is signalled typographically (mono font) + # rather than rendered: the displayed value already carries the + # formatting from _fmt(). Single quotes: the fragment lands inside a + # double-quoted HTML attribute. + 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) + + +def _sheet_style_maps(ws: Any) -> tuple[dict[str, str], dict[str, str]]: + """Flat ``{ref: css}`` and ``{ref: align}`` maps of one worksheet. + + 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. + """ + styles: dict[str, str] = {} + aligns: 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": + 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 + + +def read_sheet_styles(file_path: Path, sheet: str) -> dict[str, dict[str, Any]]: + """Return ``{ref: {style, align}}`` for the styled cells of one sheet. + + ``style`` is the flat CSS fragment the viewer applies verbatim and + ``align`` the horizontal text-align when it is not the table default. + Normal (non-streaming) load — styles are unavailable in read_only mode; + a failure yields ``{}`` so the viewer falls back to the plain rendering. + """ + meta = read_workbook_meta(file_path).get(sheet, {}) + styles_map = meta.get("styles", {}) + aligns = meta.get("aligns", {}) + out: dict[str, dict[str, Any]] = {} + for ref, css in styles_map.items(): + entry: dict[str, Any] = {"style": css} + if ref in aligns: + entry["align"] = aligns[ref] + out[ref] = entry + return out + + +def read_sheet_merges(file_path: Path, sheet: str) -> list[str]: + """Return the merged ranges of one sheet as ``A1:C3`` strings.""" + try: + # Styles and merges are only fully materialised in normal mode + # (read_only=True leaves merged_cells empty). + wb = load_workbook(str(file_path)) + except Exception: + return [] + try: + if sheet not in wb.sheetnames: + return [] + merged = getattr(wb[sheet], "merged_cells", None) + ranges = getattr(merged, "ranges", None) or [] + return [str(r) for r in ranges] + except Exception: + logger.debug("xlsx merges unavailable", exc_info=True) + return [] + finally: + wb.close() + + +def read_sheet_freeze(file_path: Path, sheet: str) -> str: + """Return the freeze-panes anchor of one sheet ('' when not frozen). + + Normal (non-streaming) load: `freeze_panes` is NOT materialised on + ReadOnlyWorksheet in openpyxl 3.1.x — read_only=True always yields ''. + """ + try: + wb = load_workbook(str(file_path)) + except Exception: + return "" + try: + if sheet not in wb.sheetnames: + return "" + return str(getattr(wb[sheet], "freeze_panes", None) or "") + except Exception: + return "" + finally: + wb.close() + def _fmt(value: Any) -> str: if value is None: @@ -108,6 +232,7 @@ def _table( grid: list[list[str]], cached: list[list[str]] | None = None, row_offset: int = 0, + styles: dict[str, dict[str, Any]] | None = None, ) -> str: """Render a grid as an HTML table. @@ -121,6 +246,11 @@ def _table( ``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. + + ``styles`` maps A1 references to ``{style, align}`` fragments (#153 A15: + bold, italic, background, alignment) — the backend only reads the + workbook, the fragments are built from it and always data-driven, never + hardcoded colors. A plain ``str`` value is tolerated (legacy callers). """ if not grid: return "

Feuille vide

" @@ -137,6 +267,20 @@ def _table( out.append(f'{r}') for c, val in enumerate(row, start=1): ref = f"{get_column_letter(c)}{r}" + meta = (styles or {}).get(ref) + if meta is None: + style_attr = "" + else: + # Legacy callers may still pass a bare CSS string. + if isinstance(meta, str): + meta = {"style": meta} + fragment = meta.get("style", "") + align = meta.get("align") + if align and align not in ("left",): + # left is the table default; only non-default alignments + # need an explicit declaration. + fragment = f"{fragment};text-align:{align}" if fragment else f"text-align:{align}" + style_attr = f' style="{fragment}"' if fragment else "" # The cached result only makes sense for a formula cell: on a plain # value cell the two reads are identical and showing both would # duplicate the text. @@ -153,7 +297,7 @@ def _table( f"{html.escape(cval)}" ) out.append( - f'{html.escape(val)}{shadow}' + f'{html.escape(val)}{shadow}' ) out.append("") out.append("") @@ -205,6 +349,40 @@ def inspect_workbook(file_path: Path) -> list[str]: return [] +def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]: + """Return ``{sheet: {styles, aligns, merges, freeze}}`` for every sheet. + + One normal (non-streaming) load serves the three A15 metadata maps: the + 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. + """ + try: + wb = load_workbook(str(file_path), data_only=False) + except Exception: + return {} + out: dict[str, dict[str, Any]] = {} + try: + for ws in wb.worksheets: + styles, aligns = _sheet_style_maps(ws) + merged = getattr(ws, "merged_cells", None) + ranges = getattr(merged, "ranges", None) or [] + out[ws.title] = { + "styles": styles, + "aligns": aligns, + "merges": [str(r) for r in ranges], + "freeze": str(getattr(ws, "freeze_panes", None) or ""), + } + return out + except Exception: + logger.debug("xlsx meta unavailable", exc_info=True) + for t in wb.sheetnames: + out.setdefault(t, {"styles": {}, "aligns": {}, "merges": [], "freeze": ""}) + return out + finally: + wb.close() + + def render_sheets(file_path: Path) -> list[dict[str, Any]]: """Return one dict per sheet: ``{name, html, rows, cols, total_*, truncated}``. @@ -230,6 +408,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]: if _has_cached_values(file_path): cached = _read_cached_grids(file_path, titles) + # #153 A15 — one extra normal-mode load serves the styles/merges/freeze + # metadata of every sheet; the HTML then carries the fragments itself. + meta = read_workbook_meta(file_path) + sheets = [] for i, title in enumerate(titles): grid = _trim(formulas[i]) @@ -239,10 +421,11 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]: # positionally against the untrimmed grid keeps the two aligned. shadow = cached[i] if cached is not None and i < len(cached) else None total_rows, total_cols = extents[i] + sheet_meta = meta.get(title, {}) sheets.append( { "name": title, - "html": _table(grid, shadow), + "html": _table(grid, shadow, styles=sheet_meta.get("styles")), "rows": len(grid), "cols": max((len(r) for r in grid), default=0), "total_rows": total_rows, @@ -257,6 +440,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]: # 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, + "styles": sheet_meta.get("styles", {}), + "aligns": sheet_meta.get("aligns", {}), + "merges": sheet_meta.get("merges", []), + "freeze": sheet_meta.get("freeze", ""), } ) return sheets @@ -300,6 +487,10 @@ def read_sheet_window( # archive really holds cached results. if _has_cached_values(file_path): shadow = _read_cached_window(file_path, sheet, offset, limit) + # #153 A15 — same metadata as the full render, so a lazy window is + # indistinguishable from it (styles in the HTML, merges/freeze for the + # client-side spanning). + meta = read_workbook_meta(file_path).get(sheet, {}) return { "sheet": sheet, "offset": offset, @@ -312,7 +503,11 @@ def read_sheet_window( "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), + "html": _table(grid, shadow, row_offset=offset, styles=meta.get("styles")), + "styles": meta.get("styles", {}), + "aligns": meta.get("aligns", {}), + "merges": meta.get("merges", []), + "freeze": meta.get("freeze", ""), } diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index b53fa82..391b3b0 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.36.0" +version = "2.37.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index d65de8b..62457f6 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.36.0" +version = "2.37.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 9931ba5..63bc464 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.36.0", + "version": "2.37.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f08aaaf..1aea8a7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.36.0 | **Dernière mise à jour :** 2026-09-28 +> **Version :** 2.37.0 | **Dernière mise à jour :** 2026-09-28 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index be11a41..f054593 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -1047,6 +1047,59 @@ export function renderXlsxViewer(area, data) { `` ).join("")}` : ""; + // #153 A15 — inline styles (bold/italic/colors from the file itself), merged + // ranges and the freeze anchor are applied AFTER the panels are in the DOM + // (the HTML string alone cannot carry them: col/row spans need the table). + const applySheetMeta = (panel) => { + const meta = sheets[Number(panel.dataset.sheet)] || {}; + const styleMap = meta.styles || {}; + const alignMap = meta.aligns || {}; + panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => { + const ref = td.dataset.cell; + const style = styleMap[ref]; + if (style) td.setAttribute("style", style); + const align = alignMap[ref]; + if (align) td.style.textAlign = align; + }); + // Merged ranges: span the anchor cell over the range and hide the covered + // cells (rowSpan/colSpan survive a re-render; hidden cells are skipped by + // the save collector because their text was already blank in openpyxl). + (meta.merges || []).forEach((range) => { + const [from, to] = range.split(":"); + const anchor = panel.querySelector(`td[data-cell="${from}"]`); + const last = panel.querySelector(`td[data-cell="${to}"]`); + if (!anchor || !last || anchor === last) return; + const a = parseRef(from); + const b = parseRef(to); + if (!a || !b) return; + anchor.rowSpan = Math.max(1, b.row - a.row + 1); + anchor.colSpan = Math.max(1, b.col - a.col + 1); + // Hide every covered cell of the block. + for (let r = a.row; r <= b.row; r++) { + for (let c = a.col; c <= b.col; c++) { + if (r === a.row && c === a.col) continue; + const covered = panel.querySelector(`td[data-cell="${columnName(c)}${r}"]`); + if (covered) covered.style.display = "none"; + } + } + }); + // Freeze panes: sticky the rows/cols left of and above the anchor. + const freeze = meta.freeze; + if (freeze && freeze !== "A1") { + const f = parseRef(freeze); + if (f) { + panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => { + const ref = tr.querySelector("td")?.dataset.cell || ""; + const p = parseRef(ref); + if (p && p.row < f.row) tr.classList.add("xlsx-frozen-row"); + }); + panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => { + const p = parseRef(td.dataset.cell); + if (p && p.col < f.col) td.classList.add("xlsx-frozen-col"); + }); + } + } + }; const panels = sheets.map((s, i) => `` ).join(""); @@ -1151,6 +1204,20 @@ export function renderXlsxViewer(area, data) { panel.querySelectorAll("tbody tr").forEach((tr) => tr.setAttribute("data-wired", "1")); const cachedEls = panel.querySelectorAll(".xlsx-cached[data-cached-value]"); cachedEls.forEach((el) => { if (!el.title) el.title = t("xlsx.cached_value_title"); }); + // #153 A15 — the window carries its own style/merge metadata: fold it + // into the sheet meta and re-apply, so lazy rows look like the initial + // render (applySheetMeta is idempotent over already-processed cells). + const sheetMeta = sheets[Number(panel.dataset.sheet)]; + if (sheetMeta) { + Object.assign(sheetMeta.styles || (sheetMeta.styles = {}), win.styles || {}); + Object.assign(sheetMeta.aligns || (sheetMeta.aligns = {}), win.aligns || {}); + if (!sheetMeta.merges) sheetMeta.merges = []; + (win.merges || []).forEach((m) => { + if (!sheetMeta.merges.includes(m)) sheetMeta.merges.push(m); + }); + if (win.freeze && !sheetMeta.freeze) sheetMeta.freeze = win.freeze; + applySheetMeta(panel); + } safeCreateIcons(); }; @@ -1327,6 +1394,7 @@ export function renderXlsxViewer(area, data) { // Editable cells: Enter blurs, Escape reverts, arrows/Tab navigate. area.querySelectorAll(".xlsx-table td").forEach(setupCell); panelEls.forEach(wireLazyRows); + panelEls.forEach(applySheetMeta); // The formula bar starts disabled: nothing is selected yet. setActiveCell(null); diff --git a/frontend/style.css b/frontend/style.css index e241fe5..0da30b3 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -11092,6 +11092,21 @@ body.desktop-mode .editor-container { outline-offset: 1px; } +/* #153 A15 — freeze panes: frozen rows sit UNDER the sticky thead, frozen + columns stay left. z-index mirrors thead (3) without covering it. */ +.xlsx-table tr.xlsx-frozen-row td, +.xlsx-table tr.xlsx-frozen-row th { + position: sticky; + top: 33px; /* thead height — keeps the frozen row below the header */ + z-index: 2; + background: var(--surface); +} +.xlsx-table td.xlsx-frozen-col { + position: sticky; + left: 44px; /* the row-number column width */ + background: var(--surface); +} + /* #153 A14 — structure menu (sheets / rows / columns) */ .xlsx-structure-menu { position: absolute; diff --git a/package.json b/package.json index 15eb3a0..72bac5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.36.0", + "version": "2.37.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/test_xlsx_styles.py b/tests/test_xlsx_styles.py new file mode 100644 index 0000000..b8638b1 --- /dev/null +++ b/tests/test_xlsx_styles.py @@ -0,0 +1,99 @@ +"""Style metadata in the .xlsx viewer (#153 A15): bold, colors, number formats, +merged ranges and freeze panes — plus the endpoint wiring.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +openpyxl = pytest.importorskip("openpyxl") + +VAULT = "TestVault" + + +@pytest.fixture +def styled_book(test_vault_dir: str) -> str: + """A workbook with a bold red-font header, a filled cell, a merge and a freeze.""" + from openpyxl.styles import Font, PatternFill + + path = Path(test_vault_dir) / "styles.xlsx" + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Data" + ws["A1"] = "En-tête" + ws["A1"].font = Font(bold=True, italic=True, color="FFCC0000") + ws["B1"] = "Total" + ws["B1"].fill = PatternFill("solid", fgColor="FFFFEE99") + ws["B2"] = 1234.5 + ws["B2"].number_format = "#,##0.00" + ws["C3"] = "fusionnée" + ws.merge_cells("C3:D3") + ws["C3"].value = "fusionnée" + ws.freeze_panes = "A2" + wb.save(path) + return str(path) + + +class TestStyleReading: + def test_bold_italic_and_font_color(self, styled_book): + from backend.xlsx_reader import read_sheet_styles + + styles = read_sheet_styles(Path(styled_book), "Data") + a1 = styles["A1"] + assert "font-weight:600" in a1["style"] + assert "font-style:italic" in a1["style"] + assert a1["style"].startswith("color:#cc0000") + + def test_background_fill_is_read(self, styled_book): + from backend.xlsx_reader import read_sheet_styles + + styles = read_sheet_styles(Path(styled_book), "Data") + assert "background:#ffee99" in styles["B1"]["style"] + + def test_number_format_is_signalled(self, styled_book): + from backend.xlsx_reader import read_sheet_styles + + styles = read_sheet_styles(Path(styled_book), "Data") + assert "font-family" in styles["B2"]["style"] + + def test_plain_cell_has_no_entry(self, styled_book): + from backend.xlsx_reader import read_sheet_styles + + styles = read_sheet_styles(Path(styled_book), "Data") + assert "A2" not in styles # untouched cell + + def test_merges_and_freeze(self, styled_book): + from backend.xlsx_reader import read_sheet_freeze, read_sheet_merges + + assert read_sheet_merges(Path(styled_book), "Data") == ["C3:D3"] + assert read_sheet_freeze(Path(styled_book), "Data") == "A2" + + def test_unknown_sheet_yields_empty(self, styled_book): + from backend.xlsx_reader import read_sheet_styles + + assert read_sheet_styles(Path(styled_book), "Nope") == {} + + def test_broken_file_yields_empty(self, test_vault_dir): + from backend.xlsx_reader import read_sheet_styles + + bad = Path(test_vault_dir) / "broken-styles.xlsx" + bad.write_bytes(b"not a zip") + assert read_sheet_styles(bad, "Data") == {} + + +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"}) + assert resp.status_code == 200 + sheet = resp.json()["xlsx_sheets"][0] + assert sheet["styles"]["A1"].startswith("color:#cc0000") + assert sheet["aligns"] == {} or isinstance(sheet["aligns"], dict) + assert sheet["merges"] == ["C3:D3"] + assert sheet["freeze"] == "A2" + + def test_rendered_html_carries_the_inline_style(self, client, styled_book): + resp = client.get(f"/api/file/{VAULT}", params={"path": "styles.xlsx"}) + html = resp.json()["xlsx_sheets"][0]["html"] + assert 'data-cell="A1" style="' in html + assert "font-weight:600" in html