diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs
index 5a0b1f857..61021f8d0 100644
--- a/crates/typst-library/src/model/table.rs
+++ b/crates/typst-library/src/model/table.rs
@@ -292,18 +292,61 @@ fn show_cellgrid_html(grid: CellGrid, styles: StyleChain) -> Content {
elem(tag::tr, Content::sequence(row))
};
+ // TODO(subfooters): similarly to headers, take consecutive footers from
+ // the end for 'tfoot'.
let footer = grid.footer.map(|ft| {
let rows = rows.drain(ft.start..);
elem(tag::tfoot, Content::sequence(rows.map(|row| tr(tag::td, row))))
});
- // TODO: Headers and footers in arbitrary positions
- // Right now, only those at either end are accepted
- let header = grid.headers.first().filter(|h| h.start == 0).map(|hd| {
- let rows = rows.drain(..hd.end);
- elem(tag::thead, Content::sequence(rows.map(|row| tr(tag::th, row))))
- });
- let mut body = Content::sequence(rows.into_iter().map(|row| tr(tag::td, row)));
+ // Store all consecutive headers at the start in 'thead'. All remaining
+ // headers are just 'th' rows across the table body.
+ let mut consecutive_header_end = 0;
+ let first_mid_table_header = grid
+ .headers
+ .iter()
+ .take_while(|hd| {
+ let is_consecutive = hd.start == consecutive_header_end;
+ consecutive_header_end = hd.end;
+
+ is_consecutive
+ })
+ .count();
+
+ let (y_offset, header) = if first_mid_table_header > 0 {
+ let removed_header_rows =
+ grid.headers.get(first_mid_table_header - 1).unwrap().end;
+ let rows = rows.drain(..removed_header_rows);
+
+ (
+ removed_header_rows,
+ Some(elem(tag::thead, Content::sequence(rows.map(|row| tr(tag::th, row))))),
+ )
+ } else {
+ (0, None)
+ };
+
+ // TODO: Consider improving accessibility properties of multi-level headers
+ // inside tables in the future, e.g. indicating which columns they are
+ // relative to and so on. See also:
+ // https://www.w3.org/WAI/tutorials/tables/multi-level/
+ let mut next_header = first_mid_table_header;
+ let mut body =
+ Content::sequence(rows.into_iter().enumerate().map(|(relative_y, row)| {
+ let y = relative_y + y_offset;
+ if let Some(current_header) =
+ grid.headers.get(next_header).filter(|h| h.range().contains(&y))
+ {
+ if y + 1 == current_header.end {
+ next_header += 1;
+ }
+
+ tr(tag::th, row)
+ } else {
+ tr(tag::td, row)
+ }
+ }));
+
if header.is_some() || footer.is_some() {
body = elem(tag::tbody, body);
}
diff --git a/tests/ref/html/multi-header-inside-table.html b/tests/ref/html/multi-header-inside-table.html
new file mode 100644
index 000000000..a4a61a697
--- /dev/null
+++ b/tests/ref/html/multi-header-inside-table.html
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+ First |
+ Header |
+
+
+ Second |
+ Header |
+
+
+ Level 2 |
+ Header |
+
+
+ Level 3 |
+ Header |
+
+
+
+
+ Body |
+ Cells |
+
+
+ Yet |
+ More |
+
+
+ Level 2 |
+ Header Inside |
+
+
+ Level 3 |
+ |
+
+
+ Even |
+ More |
+
+
+ Body |
+ Cells |
+
+
+ One Last Header |
+ For Good Measure |
+
+
+
+
+ Footer |
+ Row |
+
+
+ Ending |
+ Table |
+
+
+
+
+
diff --git a/tests/ref/html/multi-header-table.html b/tests/ref/html/multi-header-table.html
new file mode 100644
index 000000000..8a34ac170
--- /dev/null
+++ b/tests/ref/html/multi-header-table.html
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+ First |
+ Header |
+
+
+ Second |
+ Header |
+
+
+ Level 2 |
+ Header |
+
+
+ Level 3 |
+ Header |
+
+
+
+
+ Body |
+ Cells |
+
+
+ Yet |
+ More |
+
+
+
+
+ Footer |
+ Row |
+
+
+ Ending |
+ Table |
+
+
+
+
+
diff --git a/tests/suite/layout/grid/html.typ b/tests/suite/layout/grid/html.typ
index 10345cb06..cf98d4bc5 100644
--- a/tests/suite/layout/grid/html.typ
+++ b/tests/suite/layout/grid/html.typ
@@ -57,3 +57,78 @@
[d], [e], [f],
[g], [h], [i]
)
+
+--- multi-header-table html ---
+#table(
+ columns: 2,
+
+ table.header(
+ [First], [Header]
+ ),
+ table.header(
+ [Second], [Header]
+ ),
+ table.header(
+ [Level 2], [Header],
+ level: 2,
+ ),
+ table.header(
+ [Level 3], [Header],
+ level: 3,
+ ),
+
+ [Body], [Cells],
+ [Yet], [More],
+
+ table.footer(
+ [Footer], [Row],
+ [Ending], [Table],
+ ),
+)
+
+--- multi-header-inside-table html ---
+#table(
+ columns: 2,
+
+ table.header(
+ [First], [Header]
+ ),
+ table.header(
+ [Second], [Header]
+ ),
+ table.header(
+ [Level 2], [Header],
+ level: 2,
+ ),
+ table.header(
+ [Level 3], [Header],
+ level: 3,
+ ),
+
+ [Body], [Cells],
+ [Yet], [More],
+
+ table.header(
+ [Level 2], [Header Inside],
+ level: 2,
+ ),
+ table.header(
+ [Level 3],
+ level: 3,
+ ),
+
+ [Even], [More],
+ [Body], [Cells],
+
+ table.header(
+ [One Last Header],
+ [For Good Measure],
+ repeat: false,
+ level: 4,
+ ),
+
+ table.footer(
+ [Footer], [Row],
+ [Ending], [Table],
+ ),
+)