diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py
index def9c9b30f2206f86f0b879ad4341a96806d25b6..11dd02f916d7124bfd0307b485bbe4d97aff34d5 100644
--- a/app/scodoc/sco_recapcomplet.py
+++ b/app/scodoc/sco_recapcomplet.py
@@ -63,6 +63,7 @@ def formsemestre_recapcomplet(
     xml_with_decisions=False,
     force_publishing=True,
     selected_etudid=None,
+    visible_col_ids=None,
 ):
     """Page récapitulant les notes d'un semestre.
     Grand tableau récapitulatif avec toutes les notes de modules
@@ -86,7 +87,7 @@ def formsemestre_recapcomplet(
     if not isinstance(formsemestre_id, int):
         abort(404)
     formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
-    file_formats = {"csv", "json", "xls", "xlsx", "xlsall", "xml"}
+    file_formats = {"csv", "json", "xls", "xlsx", "xlsall", "xlsvisible", "xml"}
     supported_formats = file_formats | {"html", "evals"}
     if tabformat not in supported_formats:
         raise ScoValueError(f"Format non supporté: {tabformat}")
@@ -94,6 +95,7 @@ def formsemestre_recapcomplet(
     mode_jury = int(mode_jury)
     xml_with_decisions = int(xml_with_decisions)
     force_publishing = int(force_publishing)
+    visible_col_ids = visible_col_ids.split(",") if visible_col_ids else None
     filename = scu.sanitize_filename(
         f"""{'jury' if mode_jury else 'recap'
             }{'-evals' if tabformat == 'xlsall' else ''
@@ -107,6 +109,7 @@ def formsemestre_recapcomplet(
             filename=filename,
             xml_with_decisions=xml_with_decisions,
             force_publishing=force_publishing,
+            visible_col_ids=visible_col_ids,
         )
 
     table_html, _, freq_codes_annuels = _formsemestre_recapcomplet_to_html(
@@ -124,8 +127,9 @@ def formsemestre_recapcomplet(
     ]
     if len(formsemestre.inscriptions) > 0:
         H.append(
-            f"""<form name="f" method="get" action="{request.base_url}">
+            f"""<form id="export_menu" name="f" method="get" action="{request.base_url}">
             <input type="hidden" name="formsemestre_id" value="{formsemestre_id}"></input>
+            <input type="hidden" id="visible_col_ids" name="visible_col_ids" value=""></input>
             """
         )
         if mode_jury:
@@ -133,13 +137,14 @@ def formsemestre_recapcomplet(
                 f'<input type="hidden" name="mode_jury" value="{mode_jury}"></input>'
             )
         H.append(
-            '<select name="tabformat" onchange="document.f.submit()" class="noprint">'
+            '<select name="tabformat" id="tabformat" onchange="submit_from_export_menu();" class="noprint">'
         )
         for fmt, label in (
             ("html", "Tableau"),
             ("evals", "Avec toutes les évaluations"),
             ("xlsx", "Excel (non formaté)"),
             ("xlsall", "Excel avec évaluations"),
+            ("xlsvisible", "Excel avec colonnes telles affichées"),
             ("json", "Bulletins JSON"),
         ):
             if fmt == tabformat:
@@ -314,16 +319,19 @@ def _formsemestre_recapcomplet_to_file(
     xml_nodate=False,  # format XML sans dates (sert pour debug cache: comparaison de XML)
     xml_with_decisions=False,
     force_publishing=True,
+    visible_col_ids=None,
 ):
     """Calcule et renvoie le tableau récapitulatif."""
     if tabformat.startswith("xls"):
         include_evaluations = tabformat == "xlsall"
+        visible_col_ids = visible_col_ids if tabformat == "xlsvisible" else None
         res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
         data, filename = gen_formsemestre_recapcomplet_excel(
             res,
             mode_jury=mode_jury,
             include_evaluations=include_evaluations,
             filename=filename,
+            visible_col_ids=visible_col_ids,
         )
         mime, suffix = scu.get_mime_suffix("xlsx")
         return scu.send_file(data, filename=filename, mime=mime, suffix=suffix)
@@ -537,11 +545,13 @@ def gen_formsemestre_recapcomplet_excel(
     mode_jury: bool = False,
     include_evaluations=False,
     filename: str = "",
+    visible_col_ids: list[str] | None = None,
 ) -> tuple:
     """Génère le tableau recap ou jury en excel (xlsx).
     Utilisé pour menu (export excel), 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.
+    Si visible_col_ids est non None, ne génère que les colonnes indiquées (+ les codes étudiants, toujours présents)
     """
     # En excel, ajoute les adresses mail, si on a le droit de les voir.
     table = _gen_formsemestre_recapcomplet_table(
@@ -552,5 +562,9 @@ def gen_formsemestre_recapcomplet_excel(
         convert_values=False,
         filename=filename,
     )
-
-    return table.excel(), filename
+    if visible_col_ids is not None:
+        # Ajoute colonnes qui doivent toujours être présentes en excel:
+        for cod in ("code_nip", "etudid"):
+            if cod not in visible_col_ids:
+                visible_col_ids = [cod] + visible_col_ids
+    return table.excel(col_ids=visible_col_ids), filename
diff --git a/app/static/js/table_recap.js b/app/static/js/table_recap.js
index 8b861b7579199f76e8c0f1dbf6ebce6d8471cce1..6bfa63aa2525996761d7670c0e50a350e5581f93 100644
--- a/app/static/js/table_recap.js
+++ b/app/static/js/table_recap.js
@@ -326,3 +326,31 @@ $(function () {
     }
   });
 });
+
+// liste des id de colonnes visibles, dans leur ordre d'affichage
+// (chaine avec ids séparés par des virgules)
+function get_visible_column_ids() {
+  const table = $("table.table_recap").DataTable();
+  const visibles = table.columns().visible();
+  let col_ids = "";
+  for (i=0; i < visibles.length; i++) {
+    if (visibles[i]) {
+      let th = table.column(i).header();
+      if (col_ids.length) {
+        col_ids += ",";
+      }
+      col_ids += th.dataset.col_id;
+    }
+  }
+  return col_ids;
+}
+
+function submit_from_export_menu() {
+  let form = document.querySelector("#export_menu");
+  let tabformat = document.getElementById("tabformat").value;
+  if (tabformat == "xlsvisible") {
+    let cols_input = document.getElementById("visible_col_ids");
+    cols_input.value = get_visible_column_ids();
+  }
+  form.submit();
+}
\ No newline at end of file
diff --git a/app/tables/table_builder.py b/app/tables/table_builder.py
index cec24081eeabc966021ded06cd67ab434b07e6e0..466ac8e8a97cde8065ba6dbbf6e6aa5091651a49 100644
--- a/app/tables/table_builder.py
+++ b/app/tables/table_builder.py
@@ -90,8 +90,8 @@ class Table(Element):
         "ordered list of column groups names"
         self.group_titles = {}
         "title (in header top row) for the group"
-        self.head = []
-        self.foot = []
+        self.head: list["Row"] = []
+        self.foot: list["Row"] = []
         self.column_group = {}
         "the group of the column: { col_id : group }"
         self.column_classes: defaultdict[str, set[str]] = defaultdict(set)
@@ -281,6 +281,7 @@ class Table(Element):
                     col_id,
                     None,
                     title,
+                    attrs={"data-col_id": col_id},
                     classes=classes,
                     group=self.column_group.get(col_id),
                     raw_content=raw_title or title,
@@ -297,8 +298,10 @@ class Table(Element):
         foot_cell = self.foot_title_row.cells[col_id] if self.foot_title_row else None
         return head_cell, foot_cell
 
-    def excel(self, wb: Workbook = None):
-        """Simple Excel representation of the table."""
+    def excel(self, wb: Workbook = None, col_ids=None):
+        """Simple Excel representation of the table.
+        Si col_ids(liste d'ids) est spécifié, ne génère que ces colonnes, dans l'ordre.
+        """
         self._prepare()
         if wb is None:
             sheet = sco_excel.ScoExcelSheet(sheet_name=self.xls_sheet_name, wb=wb)
@@ -309,13 +312,13 @@ class Table(Element):
         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))
+            sheet.append_row(row.to_excel(sheet, style=style_bold, col_ids=col_ids))
 
         for row in self.rows:
-            sheet.append_row(row.to_excel(sheet, style=style_base))
+            sheet.append_row(row.to_excel(sheet, style=style_base, col_ids=col_ids))
 
         for row in self.foot:
-            sheet.append_row(row.to_excel(sheet, style=style_base))
+            sheet.append_row(row.to_excel(sheet, style=style_base, col_ids=col_ids))
 
         if self.caption:
             sheet.append_blank_row()  # empty line
@@ -325,9 +328,10 @@ class Table(Element):
             sheet.append_single_cell_row(self.origin, style_base)
 
         # Largeurs des colonnes
+        actual_col_ids = col_ids if col_ids else self.column_ids
         for col_id, width in self.xls_columns_width.items():
             try:
-                idx = self.column_ids.index(col_id)
+                idx = actual_col_ids.index(col_id)
                 col = get_column_letter(idx + 1)
                 sheet.set_column_dimension_width(col, width)
             except ValueError:
@@ -365,7 +369,7 @@ class Row(Element):
         title: str,
         content: str,
         group: str = None,
-        attrs: list[str] = None,
+        attrs: dict[str, str] = None,
         classes: list[str] = None,
         data: dict[str, str] = None,
         elt: str = None,
@@ -466,9 +470,15 @@ class Row(Element):
             for col_id in self.table.raw_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)
+    def to_excel(self, sheet, style=None, col_ids=None) -> list:
+        """Build excel row for given sheet.
+        If col_ids is given, generate only this columns
+        """
+        if col_ids is None:
+            return sheet.make_row(self.to_dict().values(), style=style)
+        # Version avec seulement colonnes spécifiées:
+        d = self.to_dict()
+        return sheet.make_row([d[k] for k in col_ids if k in d], style=style)
 
 
 class BottomRow(Row):