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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FirstHeader
SecondHeader
Level 2Header
Level 3Header
BodyCells
YetMore
Level 2Header Inside
Level 3
EvenMore
BodyCells
One Last HeaderFor Good Measure
FooterRow
EndingTable
+ + 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FirstHeader
SecondHeader
Level 2Header
Level 3Header
BodyCells
YetMore
FooterRow
EndingTable
+ + 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], + ), +)