diff --git a/app/scodoc/sco_archives.py b/app/scodoc/sco_archives.py
index 71ff5c466862d0e7bc8ccce592dede076adf8230..5d09555435c3cffbe729d5cf6c341101e4643bf0 100644
--- a/app/scodoc/sco_archives.py
+++ b/app/scodoc/sco_archives.py
@@ -334,9 +334,7 @@ def do_formsemestre_archive(
     etudids = [m["etudid"] for m in groups_infos.members]
 
     # Tableau recap notes en XLS (pour tous les etudiants, n'utilise pas les groupes)
-    data, _ = gen_formsemestre_recapcomplet_excel(
-        formsemestre, res, include_evaluations=True, format="xls"
-    )
+    data, _ = gen_formsemestre_recapcomplet_excel(res, include_evaluations=True)
     if data:
         PVArchive.store(archive_id, "Tableau_moyennes" + scu.XLSX_SUFFIX, data)
     # Tableau recap notes en HTML (pour tous les etudiants, n'utilise pas les groupes)
diff --git a/app/scodoc/sco_excel.py b/app/scodoc/sco_excel.py
index dc875ca9382fe812e3b40071c6b4fee9a83877b2..553579ea5a537ede21c6650d0cfefab001fc1288 100644
--- a/app/scodoc/sco_excel.py
+++ b/app/scodoc/sco_excel.py
@@ -194,12 +194,12 @@ class ScoExcelSheet:
     * pour finir appel de la méthode de génération
     """
 
-    def __init__(self, sheet_name="feuille", default_style=None, wb=None):
+    def __init__(self, sheet_name="feuille", default_style=None, wb: Workbook = None):
         """Création de la feuille. sheet_name
         -- le nom de la feuille default_style
         -- le style par défaut des cellules ws
-        -- None si la feuille est autonome (dans ce cas ell crée son propre wb), sinon c'est la worksheet
-        créée par le workbook propriétaire un workbook est crée et associé à cette feuille.
+        -- None si la feuille est autonome (dans ce cas elle crée son propre wb), sinon c'est la worksheet
+        créée par le workbook propriétaire un workbook est créé et associé à cette feuille.
         """
         # Le nom de la feuille ne peut faire plus de 31 caractères.
         # si la taille du nom de feuille est > 31 on tronque (on pourrait remplacer par 'feuille' ?)
diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py
index 458aca126034e6da0527886dd9859d9751bf068d..7343ceb2e8ee45668e3efb673519f4c482452f64 100644
--- a/app/scodoc/sco_recapcomplet.py
+++ b/app/scodoc/sco_recapcomplet.py
@@ -225,16 +225,14 @@ def _do_formsemestre_recapcomplet(
             selected_etudid=selected_etudid,
         )
         return data
-    elif format.startswith("xls") or format == "csv":
+    elif format.startswith("xls"):
         res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
-        include_evaluations = format in {"xlsall", "csv "}
+        include_evaluations = format in {"xlsall", "csv "}  # csv not supported anymore
         if format != "csv":
             format = "xlsx"
         data, filename = gen_formsemestre_recapcomplet_excel(
-            formsemestre,
             res,
             include_evaluations=include_evaluations,
-            format=format,
             filename=filename,
         )
         return scu.send_file(data, filename=filename, mime=scu.get_mime_suffix(format))
@@ -446,33 +444,22 @@ def _gen_formsemestre_recapcomplet_html(
 
 
 def gen_formsemestre_recapcomplet_excel(
-    formsemestre: FormSemestre,
     res: NotesTableCompat,
     include_evaluations=False,
     filename: str = "",
-    format="xls",
 ) -> tuple:
-    """Génère le tableau recap en excel (xlsx) ou CSV.
+    """Génère le tableau recap en excel (xlsx).
     Utilisé pour archives et autres besoins particuliers (API).
     Attention: le tableau exporté depuis la page html est celui généré en js par DataTables,
      et non celui-ci.
     """
-    suffix = scu.CSV_SUFFIX if format == "csv" else scu.XLSX_SUFFIX
-    filename += suffix
+    filename += scu.XLSX_SUFFIX
 
-    # XXX TODO A ADAPTER XXX !!! !!!
     table = TableRecap(
         res,
         convert_values=False,
         include_evaluations=include_evaluations,
-        preferences=sco_preferences.SemPreferences(formsemestre_id=formsemestre.id),
+        # preferences=sco_preferences.SemPreferences(formsemestre_id=formsemestre.id),
     )
 
-    # tab = GenTable(
-    #     columns_ids=column_ids,
-    #     titles=titles,
-    #     rows=rows + footer_rows,
-    #     preferences=sco_preferences.SemPreferences(formsemestre_id=formsemestre.id),
-    # )
-
-    return table.gen(format=format), filename
+    return table.excel(), filename
diff --git a/app/tables/table_builder.py b/app/tables/table_builder.py
index 19e14ea17c0655fa937fd8d9410b391bff777d97..50a61f559e5f4341f118ac91377fb530daae9a2e 100644
--- a/app/tables/table_builder.py
+++ b/app/tables/table_builder.py
@@ -8,8 +8,15 @@
 """
 from collections import defaultdict
 
+from openpyxl import Workbook
+from openpyxl.utils import get_column_letter
+
+from app.scodoc import sco_excel
+
 
 class Element:
+    "Element de base pour les tables"
+
     def __init__(
         self,
         elt: str,
@@ -49,18 +56,6 @@ class Element:
 class Table(Element):
     """Construction d'une table de résultats
 
-    table = Table()
-    row = table.new_row(id="xxx", category="yyy")
-    row.new_cell( col_id, title, content [,classes] [, idx], [group], [keys:dict={}] )
-
-    rows = table.get_rows([category="yyy"])
-    table.sort_rows(key [, reverse])
-    table.set_titles(titles)
-    table.update_titles(titles)
-
-    table.set_column_groups(groups: list[str])
-    table.insert_group(group:str, [after=str], [before=str])
-
     Ordre des colonnes: groupées par groupes, et dans chaque groupe par ordre d'insertion
     On fixe l'ordre des groupes par ordre d'insertion
        ou par insert_group ou par set_column_groups.
@@ -74,6 +69,12 @@ class Table(Element):
         attrs: dict[str, str] = None,
         data: dict = None,
         row_class=None,
+        xls_sheet_name="feuille",
+        xls_before_table=[],  # liste de cellules a placer avant la table
+        xls_style_base=None,  # style excel pour les cellules
+        xls_columns_width=None,  # { col_id : largeur en "pixels excel" }
+        caption="",
+        origin="",
     ):
         super().__init__("table", classes=classes, attrs=attrs, data=data)
         self.row_class = row_class or Row
@@ -103,6 +104,14 @@ class Table(Element):
             self, "title_foot", cell_elt="th", classes=["titles"]
         )
         self.empty_cell = Cell.empty()
+        # Excel (xls) spécifique:
+        self.xls_before_table = xls_before_table
+        self.xls_columns_width = xls_columns_width or {}
+        self.xls_sheet_name = xls_sheet_name
+        self.xls_style_base = xls_style_base
+        #
+        self.caption = caption
+        self.origin = origin
 
     def _prepare(self):
         """Prepare the table before generation:
@@ -130,7 +139,7 @@ class Table(Element):
         self.selected_row_id = row_id
 
     def to_list(self) -> list[dict]:
-        """as a list, each row is a dict"""
+        """as a list, each row is a dict (sans les lignes d'en-tête ni de pied de table)"""
         self._prepare()
         return [row.to_dict() for row in self.rows]
 
@@ -265,6 +274,45 @@ class Table(Element):
 
         return self.head_title_row.cells.get(col_id), self.foot_title_row.cells[col_id]
 
+    def excel(self, wb: Workbook = None):
+        """Simple Excel representation of the table."""
+        self._prepare()
+        if wb is None:
+            sheet = sco_excel.ScoExcelSheet(sheet_name=self.xls_sheet_name, wb=wb)
+        else:
+            sheet = wb.create_sheet(sheet_name=self.xls_sheet_name)
+        sheet.rows += self.xls_before_table
+        style_bold = sco_excel.excel_make_style(bold=True)
+        style_base = self.xls_style_base or sco_excel.excel_make_style()
+
+        for row in self.head:
+            sheet.append_row(row.to_excel(sheet, style=style_bold))
+
+        for row in self.rows:
+            sheet.append_row(row.to_excel(sheet, style=style_base))
+
+        for row in self.foot:
+            sheet.append_row(row.to_excel(sheet, style=style_base))
+
+        if self.caption:
+            sheet.append_blank_row()  # empty line
+            sheet.append_single_cell_row(self.caption, style_base)
+        if self.origin:
+            sheet.append_blank_row()  # empty line
+            sheet.append_single_cell_row(self.origin, style_base)
+
+        # Largeurs des colonnes
+        for col_id, width in self.xls_columns_width.items():
+            try:
+                idx = self.column_ids.index(col_id)
+                col = get_column_letter(idx + 1)
+                sheet.set_column_dimension_width(col, width)
+            except ValueError:
+                pass
+
+        if wb is None:
+            return sheet.generate()
+
 
 class Row(Element):
     """A row."""
@@ -371,6 +419,10 @@ class Row(Element):
             for col_id in self.table.column_ids
         }
 
+    def to_excel(self, sheet, style=None) -> list:
+        "build excel row for given sheet"
+        return sheet.make_row(self.to_dict().values(), style=style)
+
 
 class BottomRow(Row):
     """Une ligne spéciale pour le pied de table