From 3404fecd36d58731d27b9bddbbdb50fe64b37f77 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Sat, 28 Jun 2025 18:22:30 +0200 Subject: [PATCH] feat: tag table headers and footers --- .../typst-library/src/layout/grid/resolve.rs | 25 ++- crates/typst-library/src/model/table.rs | 22 +++ crates/typst-pdf/src/tags.rs | 182 +++++++++++++++--- 3 files changed, 200 insertions(+), 29 deletions(-) diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs index baf6b7383..0de5a6b9c 100644 --- a/crates/typst-library/src/layout/grid/resolve.rs +++ b/crates/typst-library/src/layout/grid/resolve.rs @@ -22,6 +22,7 @@ use typst_syntax::Span; use typst_utils::NonZeroExt; use crate::introspection::SplitLocator; +use crate::model::TableCellKind; /// Convert a grid to a cell grid. #[typst_macros::time(span = elem.span())] @@ -217,6 +218,7 @@ impl ResolvableCell for Packed { breakable: bool, locator: Locator<'a>, styles: StyleChain, + kind: Smart, ) -> Cell<'a> { let cell = &mut *self; let colspan = cell.colspan(styles); @@ -224,6 +226,8 @@ impl ResolvableCell for Packed { let breakable = cell.breakable(styles).unwrap_or(breakable); let fill = cell.fill(styles).unwrap_or_else(|| fill.clone()); + let kind = cell.kind(styles).or(kind); + let cell_stroke = cell.stroke(styles); let stroke_overridden = cell_stroke.as_ref().map(|side| matches!(side, Some(Some(_)))); @@ -267,6 +271,7 @@ impl ResolvableCell for Packed { }), ); cell.push_breakable(Smart::Custom(breakable)); + cell.push_kind(kind); Cell { body: self.pack(), locator, @@ -312,6 +317,7 @@ impl ResolvableCell for Packed { breakable: bool, locator: Locator<'a>, styles: StyleChain, + _: Smart, ) -> Cell<'a> { let cell = &mut *self; let colspan = cell.colspan(styles); @@ -522,6 +528,7 @@ pub trait ResolvableCell { breakable: bool, locator: Locator<'a>, styles: StyleChain, + kind: Smart, ) -> Cell<'a>; /// Returns this cell's column override. @@ -1206,8 +1213,12 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // a non-empty row. let mut first_available_row = 0; + let mut cell_kind: Smart = Smart::Auto; + let (header_footer_items, simple_item) = match child { ResolvableGridChild::Header { repeat, level, span, items, .. } => { + cell_kind = Smart::Custom(TableCellKind::Header); + row_group_data = Some(RowGroupData { range: None, span, @@ -1239,6 +1250,8 @@ impl<'x> CellGridResolver<'_, '_, 'x> { bail!(span, "cannot have more than one footer"); } + cell_kind = Smart::Custom(TableCellKind::Footer); + row_group_data = Some(RowGroupData { range: None, span, @@ -1447,7 +1460,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // Let's resolve the cell so it can determine its own fields // based on its final position. - let cell = self.resolve_cell(cell, x, y, rowspan, cell_span)?; + let cell = self.resolve_cell(cell, x, y, rowspan, cell_span, cell_kind)?; if largest_index >= resolved_cells.len() { // Ensure the length of the vector of resolved cells is @@ -1542,6 +1555,10 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // and footers without having to loop through them each time. // Cells themselves, unfortunately, still have to. assert!(resolved_cells[*local_auto_index].is_none()); + let kind = match row_group.kind { + RowGroupKind::Header => TableCellKind::Header, + RowGroupKind::Footer => TableCellKind::Header, + }; resolved_cells[*local_auto_index] = Some(Entry::Cell(self.resolve_cell( T::default(), @@ -1549,6 +1566,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { first_available_row, 1, Span::detached(), + Smart::Custom(kind), )?)); group_start..group_end @@ -1673,6 +1691,9 @@ impl<'x> CellGridResolver<'_, '_, 'x> { y, 1, Span::detached(), + // FIXME: empty cells will within header and footer rows + // will prevent row group tags. + Smart::Auto, )?)) } }) @@ -1918,6 +1939,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { y: usize, rowspan: usize, cell_span: Span, + kind: Smart, ) -> SourceResult> where T: ResolvableCell + Default, @@ -1954,6 +1976,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { breakable, self.locator.next(&cell_span), self.styles, + kind, )) } } diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs index a120423b3..b10bfb002 100644 --- a/crates/typst-library/src/model/table.rs +++ b/crates/typst-library/src/model/table.rs @@ -2,6 +2,7 @@ use std::num::{NonZeroU32, NonZeroUsize}; use std::sync::Arc; use ecow::EcoString; +use typst_macros::Cast; use typst_utils::NonZeroExt; use crate::diag::{bail, HintedStrResult, HintedString, SourceResult}; @@ -810,6 +811,12 @@ pub struct TableCell { #[fold] pub stroke: Sides>>>, + // TODO: feature gate + pub kind: Smart, + + // TODO: feature gate + pub header_scope: Smart, + /// Whether rows spanned by this cell can be placed in different pages. /// When equal to `{auto}`, a cell spanning only fixed-size rows is /// unbreakable, while a cell spanning at least one `{auto}`-sized row is @@ -847,3 +854,18 @@ impl From for TableCell { value.unpack::().unwrap_or_else(Self::new) } } + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum TableHeaderScope { + Both, + Column, + Row, +} + +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum TableCellKind { + Header, + Footer, + #[default] + Data, +} diff --git a/crates/typst-pdf/src/tags.rs b/crates/typst-pdf/src/tags.rs index 8a7e1362c..911278e15 100644 --- a/crates/typst-pdf/src/tags.rs +++ b/crates/typst-pdf/src/tags.rs @@ -1,19 +1,19 @@ use std::cell::OnceCell; -use std::num::NonZeroU32; +use std::num::{NonZeroU32, NonZeroUsize}; use ecow::EcoString; use krilla::page::Page; use krilla::surface::Surface; use krilla::tagging::{ ArtifactType, ContentTag, Identifier, Node, SpanTag, TableCellSpan, TableDataCell, - TableHeaderCell, TableHeaderScope, Tag, TagBuilder, TagGroup, TagKind, TagTree, + TableHeaderCell, Tag, TagBuilder, TagGroup, TagKind, TagTree, }; -use typst_library::foundations::{Content, LinkMarker, Packed, StyleChain}; +use typst_library::foundations::{Content, LinkMarker, Packed, Smart, StyleChain}; use typst_library::introspection::Location; use typst_library::layout::RepeatElem; use typst_library::model::{ Destination, FigureCaption, FigureElem, HeadingElem, Outlinable, OutlineBody, - OutlineEntry, TableCell, TableElem, + OutlineEntry, TableCell, TableCellKind, TableElem, TableHeaderScope, }; use typst_library::pdf::{ArtifactElem, ArtifactKind, PdfTagElem, PdfTagKind}; use typst_library::visualize::ImageElem; @@ -126,7 +126,42 @@ impl OutlineCtx { pub(crate) struct TableCtx { table: Packed, - rows: Vec, Tag, Vec)>>>, + rows: Vec>, +} + +#[derive(Clone, Default)] +enum GridCell { + Cell(TableCtxCell), + Spanned(usize, usize), + #[default] + Missing, +} + +impl GridCell { + fn as_cell(&self) -> Option<&TableCtxCell> { + if let Self::Cell(v) = self { + Some(v) + } else { + None + } + } + + fn into_cell(self) -> Option { + if let Self::Cell(v) = self { + Some(v) + } else { + None + } + } +} + +#[derive(Clone)] +struct TableCtxCell { + rowspan: NonZeroUsize, + colspan: NonZeroUsize, + kind: TableCellKind, + header_scope: Smart, + nodes: Vec, } impl TableCtx { @@ -137,51 +172,134 @@ impl TableCtx { fn insert(&mut self, cell: Packed, nodes: Vec) { let x = cell.x(StyleChain::default()).unwrap_or_else(|| unreachable!()); let y = cell.y(StyleChain::default()).unwrap_or_else(|| unreachable!()); - let rowspan = cell.rowspan(StyleChain::default()).get(); - let colspan = cell.colspan(StyleChain::default()).get(); + let rowspan = cell.rowspan(StyleChain::default()); + let colspan = cell.colspan(StyleChain::default()); + let kind = cell.kind(StyleChain::default()); + let header_scope = cell.header_scope(StyleChain::default()); - // TODO: possibly set internal field on TableCell when resolving - // the cell grid. - let is_header = false; - let span = TableCellSpan { rows: rowspan as i32, cols: colspan as i32 }; - let tag = if is_header { - let scope = TableHeaderScope::Column; // TODO - TagKind::TH(TableHeaderCell::new(scope).with_span(span)) - } else { - TagKind::TD(TableDataCell::new().with_span(span)) + // The explicit cell kind takes precedence, but if it is `auto` and a + // scope was specified, make this a header cell. + let kind = match (kind, header_scope) { + (Smart::Custom(kind), _) => kind, + (Smart::Auto, Smart::Custom(_)) => TableCellKind::Header, + (Smart::Auto, Smart::Auto) => TableCellKind::Data, }; - let required_height = y + rowspan; + // Extend the table grid to fit this cell. + let required_height = y + rowspan.get(); + let required_width = x + colspan.get(); if self.rows.len() < required_height { - self.rows.resize_with(required_height, Vec::new); + self.rows + .resize(required_height, vec![GridCell::Missing; required_width]); } - - let required_width = x + colspan; let row = &mut self.rows[y]; if row.len() < required_width { - row.resize_with(required_width, || None); + row.resize_with(required_width, || GridCell::Missing); } - row[x] = Some((cell, tag.into(), nodes)); + // Store references to the cell for all spanned cells. + for i in y..y + rowspan.get() { + for j in x..x + colspan.get() { + self.rows[i][j] = GridCell::Spanned(x, y); + } + } + + self.rows[y][x] = + GridCell::Cell(TableCtxCell { rowspan, colspan, kind, header_scope, nodes }); } fn build_table(self, mut nodes: Vec) -> Vec { // Table layouting ensures that there are no overlapping cells, and that // any gaps left by the user are filled with empty cells. - for row in self.rows.into_iter() { - let mut row_nodes = Vec::new(); - for (_, tag, nodes) in row.into_iter().flatten() { - row_nodes.push(TagNode::Group(tag, nodes)); + + // Only generate row groups such as `THead`, `TFoot`, and `TBody` if + // there are no rows with mixed cell kinds. + let mut mixed_row_kinds = false; + let row_kinds = (self.rows.iter()) + .map(|row| { + row.iter() + .filter_map(|cell| match cell { + GridCell::Cell(cell) => Some(cell), + &GridCell::Spanned(x, y) => self.rows[y][x].as_cell(), + GridCell::Missing => None, + }) + .map(|cell| cell.kind) + .reduce(|a, b| { + if a != b { + mixed_row_kinds = true; + } + a + }) + .expect("tables must have at least one column") + }) + .collect::>(); + + let Some(mut chunk_kind) = row_kinds.first().copied() else { + return nodes; + }; + let mut row_chunk = Vec::new(); + for (row, row_kind) in self.rows.into_iter().zip(row_kinds) { + let row_nodes = row + .into_iter() + .filter_map(|cell| { + let cell = cell.into_cell()?; + let span = TableCellSpan { + rows: cell.rowspan.get() as i32, + cols: cell.colspan.get() as i32, + }; + let tag = match cell.kind { + TableCellKind::Header => { + let scope = match cell.header_scope { + Smart::Custom(scope) => table_header_scope(scope), + Smart::Auto => krilla::tagging::TableHeaderScope::Column, + }; + TagKind::TH(TableHeaderCell::new(scope).with_span(span)) + } + TableCellKind::Footer | TableCellKind::Data => { + TagKind::TD(TableDataCell::new().with_span(span)) + } + }; + + Some(TagNode::Group(tag.into(), cell.nodes)) + }) + .collect(); + + let row = TagNode::Group(TagKind::TR.into(), row_nodes); + + // Push the `TR` tags directly. + if mixed_row_kinds { + nodes.push(row); + continue; } - // TODO: generate `THead`, `TBody`, and `TFoot` - nodes.push(TagNode::Group(TagKind::TR.into(), row_nodes)); + // Generate row groups. + if row_kind != chunk_kind { + let tag = match chunk_kind { + TableCellKind::Header => TagKind::THead, + TableCellKind::Footer => TagKind::TFoot, + TableCellKind::Data => TagKind::TBody, + }; + nodes.push(TagNode::Group(tag.into(), std::mem::take(&mut row_chunk))); + + chunk_kind = row_kind; + } + row_chunk.push(row); + } + + if !row_chunk.is_empty() { + let tag = match chunk_kind { + TableCellKind::Header => TagKind::THead, + TableCellKind::Footer => TagKind::TFoot, + TableCellKind::Data => TagKind::TBody, + }; + nodes.push(TagNode::Group(tag.into(), row_chunk)); } nodes } } +#[derive(Clone)] pub(crate) enum TagNode { Group(Tag, Vec), Leaf(Identifier), @@ -489,6 +607,14 @@ fn start_artifact(gc: &mut GlobalContext, loc: Location, kind: ArtifactKind) { gc.tags.in_artifact = Some((loc, kind)); } +fn table_header_scope(scope: TableHeaderScope) -> krilla::tagging::TableHeaderScope { + match scope { + TableHeaderScope::Both => krilla::tagging::TableHeaderScope::Both, + TableHeaderScope::Column => krilla::tagging::TableHeaderScope::Column, + TableHeaderScope::Row => krilla::tagging::TableHeaderScope::Row, + } +} + fn artifact_type(kind: ArtifactKind) -> ArtifactType { match kind { ArtifactKind::Header => ArtifactType::Header,