feat: tag table headers and footers

This commit is contained in:
Tobias Schmitz 2025-06-28 18:22:30 +02:00
parent bfcf2bd4cc
commit 3404fecd36
No known key found for this signature in database
3 changed files with 200 additions and 29 deletions

View File

@ -22,6 +22,7 @@ use typst_syntax::Span;
use typst_utils::NonZeroExt; use typst_utils::NonZeroExt;
use crate::introspection::SplitLocator; use crate::introspection::SplitLocator;
use crate::model::TableCellKind;
/// Convert a grid to a cell grid. /// Convert a grid to a cell grid.
#[typst_macros::time(span = elem.span())] #[typst_macros::time(span = elem.span())]
@ -217,6 +218,7 @@ impl ResolvableCell for Packed<TableCell> {
breakable: bool, breakable: bool,
locator: Locator<'a>, locator: Locator<'a>,
styles: StyleChain, styles: StyleChain,
kind: Smart<TableCellKind>,
) -> Cell<'a> { ) -> Cell<'a> {
let cell = &mut *self; let cell = &mut *self;
let colspan = cell.colspan(styles); let colspan = cell.colspan(styles);
@ -224,6 +226,8 @@ impl ResolvableCell for Packed<TableCell> {
let breakable = cell.breakable(styles).unwrap_or(breakable); let breakable = cell.breakable(styles).unwrap_or(breakable);
let fill = cell.fill(styles).unwrap_or_else(|| fill.clone()); let fill = cell.fill(styles).unwrap_or_else(|| fill.clone());
let kind = cell.kind(styles).or(kind);
let cell_stroke = cell.stroke(styles); let cell_stroke = cell.stroke(styles);
let stroke_overridden = let stroke_overridden =
cell_stroke.as_ref().map(|side| matches!(side, Some(Some(_)))); cell_stroke.as_ref().map(|side| matches!(side, Some(Some(_))));
@ -267,6 +271,7 @@ impl ResolvableCell for Packed<TableCell> {
}), }),
); );
cell.push_breakable(Smart::Custom(breakable)); cell.push_breakable(Smart::Custom(breakable));
cell.push_kind(kind);
Cell { Cell {
body: self.pack(), body: self.pack(),
locator, locator,
@ -312,6 +317,7 @@ impl ResolvableCell for Packed<GridCell> {
breakable: bool, breakable: bool,
locator: Locator<'a>, locator: Locator<'a>,
styles: StyleChain, styles: StyleChain,
_: Smart<TableCellKind>,
) -> Cell<'a> { ) -> Cell<'a> {
let cell = &mut *self; let cell = &mut *self;
let colspan = cell.colspan(styles); let colspan = cell.colspan(styles);
@ -522,6 +528,7 @@ pub trait ResolvableCell {
breakable: bool, breakable: bool,
locator: Locator<'a>, locator: Locator<'a>,
styles: StyleChain, styles: StyleChain,
kind: Smart<TableCellKind>,
) -> Cell<'a>; ) -> Cell<'a>;
/// Returns this cell's column override. /// Returns this cell's column override.
@ -1206,8 +1213,12 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// a non-empty row. // a non-empty row.
let mut first_available_row = 0; let mut first_available_row = 0;
let mut cell_kind: Smart<TableCellKind> = Smart::Auto;
let (header_footer_items, simple_item) = match child { let (header_footer_items, simple_item) = match child {
ResolvableGridChild::Header { repeat, level, span, items, .. } => { ResolvableGridChild::Header { repeat, level, span, items, .. } => {
cell_kind = Smart::Custom(TableCellKind::Header);
row_group_data = Some(RowGroupData { row_group_data = Some(RowGroupData {
range: None, range: None,
span, span,
@ -1239,6 +1250,8 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
bail!(span, "cannot have more than one footer"); bail!(span, "cannot have more than one footer");
} }
cell_kind = Smart::Custom(TableCellKind::Footer);
row_group_data = Some(RowGroupData { row_group_data = Some(RowGroupData {
range: None, range: None,
span, span,
@ -1447,7 +1460,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// Let's resolve the cell so it can determine its own fields // Let's resolve the cell so it can determine its own fields
// based on its final position. // 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() { if largest_index >= resolved_cells.len() {
// Ensure the length of the vector of resolved cells is // 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. // and footers without having to loop through them each time.
// Cells themselves, unfortunately, still have to. // Cells themselves, unfortunately, still have to.
assert!(resolved_cells[*local_auto_index].is_none()); 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] = resolved_cells[*local_auto_index] =
Some(Entry::Cell(self.resolve_cell( Some(Entry::Cell(self.resolve_cell(
T::default(), T::default(),
@ -1549,6 +1566,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
first_available_row, first_available_row,
1, 1,
Span::detached(), Span::detached(),
Smart::Custom(kind),
)?)); )?));
group_start..group_end group_start..group_end
@ -1673,6 +1691,9 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
y, y,
1, 1,
Span::detached(), 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, y: usize,
rowspan: usize, rowspan: usize,
cell_span: Span, cell_span: Span,
kind: Smart<TableCellKind>,
) -> SourceResult<Cell<'x>> ) -> SourceResult<Cell<'x>>
where where
T: ResolvableCell + Default, T: ResolvableCell + Default,
@ -1954,6 +1976,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
breakable, breakable,
self.locator.next(&cell_span), self.locator.next(&cell_span),
self.styles, self.styles,
kind,
)) ))
} }
} }

View File

@ -2,6 +2,7 @@ use std::num::{NonZeroU32, NonZeroUsize};
use std::sync::Arc; use std::sync::Arc;
use ecow::EcoString; use ecow::EcoString;
use typst_macros::Cast;
use typst_utils::NonZeroExt; use typst_utils::NonZeroExt;
use crate::diag::{bail, HintedStrResult, HintedString, SourceResult}; use crate::diag::{bail, HintedStrResult, HintedString, SourceResult};
@ -810,6 +811,12 @@ pub struct TableCell {
#[fold] #[fold]
pub stroke: Sides<Option<Option<Arc<Stroke>>>>, pub stroke: Sides<Option<Option<Arc<Stroke>>>>,
// TODO: feature gate
pub kind: Smart<TableCellKind>,
// TODO: feature gate
pub header_scope: Smart<TableHeaderScope>,
/// Whether rows spanned by this cell can be placed in different pages. /// Whether rows spanned by this cell can be placed in different pages.
/// When equal to `{auto}`, a cell spanning only fixed-size rows is /// When equal to `{auto}`, a cell spanning only fixed-size rows is
/// unbreakable, while a cell spanning at least one `{auto}`-sized row is /// unbreakable, while a cell spanning at least one `{auto}`-sized row is
@ -847,3 +854,18 @@ impl From<Content> for TableCell {
value.unpack::<Self>().unwrap_or_else(Self::new) value.unpack::<Self>().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,
}

View File

@ -1,19 +1,19 @@
use std::cell::OnceCell; use std::cell::OnceCell;
use std::num::NonZeroU32; use std::num::{NonZeroU32, NonZeroUsize};
use ecow::EcoString; use ecow::EcoString;
use krilla::page::Page; use krilla::page::Page;
use krilla::surface::Surface; use krilla::surface::Surface;
use krilla::tagging::{ use krilla::tagging::{
ArtifactType, ContentTag, Identifier, Node, SpanTag, TableCellSpan, TableDataCell, 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::introspection::Location;
use typst_library::layout::RepeatElem; use typst_library::layout::RepeatElem;
use typst_library::model::{ use typst_library::model::{
Destination, FigureCaption, FigureElem, HeadingElem, Outlinable, OutlineBody, 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::pdf::{ArtifactElem, ArtifactKind, PdfTagElem, PdfTagKind};
use typst_library::visualize::ImageElem; use typst_library::visualize::ImageElem;
@ -126,7 +126,42 @@ impl OutlineCtx {
pub(crate) struct TableCtx { pub(crate) struct TableCtx {
table: Packed<TableElem>, table: Packed<TableElem>,
rows: Vec<Vec<Option<(Packed<TableCell>, Tag, Vec<TagNode>)>>>, rows: Vec<Vec<GridCell>>,
}
#[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<TableCtxCell> {
if let Self::Cell(v) = self {
Some(v)
} else {
None
}
}
}
#[derive(Clone)]
struct TableCtxCell {
rowspan: NonZeroUsize,
colspan: NonZeroUsize,
kind: TableCellKind,
header_scope: Smart<TableHeaderScope>,
nodes: Vec<TagNode>,
} }
impl TableCtx { impl TableCtx {
@ -137,51 +172,134 @@ impl TableCtx {
fn insert(&mut self, cell: Packed<TableCell>, nodes: Vec<TagNode>) { fn insert(&mut self, cell: Packed<TableCell>, nodes: Vec<TagNode>) {
let x = cell.x(StyleChain::default()).unwrap_or_else(|| unreachable!()); let x = cell.x(StyleChain::default()).unwrap_or_else(|| unreachable!());
let y = cell.y(StyleChain::default()).unwrap_or_else(|| unreachable!()); let y = cell.y(StyleChain::default()).unwrap_or_else(|| unreachable!());
let rowspan = cell.rowspan(StyleChain::default()).get(); let rowspan = cell.rowspan(StyleChain::default());
let colspan = cell.colspan(StyleChain::default()).get(); 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 explicit cell kind takes precedence, but if it is `auto` and a
// the cell grid. // scope was specified, make this a header cell.
let is_header = false; let kind = match (kind, header_scope) {
let span = TableCellSpan { rows: rowspan as i32, cols: colspan as i32 }; (Smart::Custom(kind), _) => kind,
let tag = if is_header { (Smart::Auto, Smart::Custom(_)) => TableCellKind::Header,
let scope = TableHeaderScope::Column; // TODO (Smart::Auto, Smart::Auto) => TableCellKind::Data,
TagKind::TH(TableHeaderCell::new(scope).with_span(span))
} else {
TagKind::TD(TableDataCell::new().with_span(span))
}; };
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 { 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]; let row = &mut self.rows[y];
if row.len() < required_width { 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<TagNode>) -> Vec<TagNode> { fn build_table(self, mut nodes: Vec<TagNode>) -> Vec<TagNode> {
// Table layouting ensures that there are no overlapping cells, and that // Table layouting ensures that there are no overlapping cells, and that
// any gaps left by the user are filled with empty cells. // any gaps left by the user are filled with empty cells.
for row in self.rows.into_iter() {
let mut row_nodes = Vec::new(); // Only generate row groups such as `THead`, `TFoot`, and `TBody` if
for (_, tag, nodes) in row.into_iter().flatten() { // there are no rows with mixed cell kinds.
row_nodes.push(TagNode::Group(tag, nodes)); 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::<Vec<_>>();
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` // Generate row groups.
nodes.push(TagNode::Group(TagKind::TR.into(), row_nodes)); 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 nodes
} }
} }
#[derive(Clone)]
pub(crate) enum TagNode { pub(crate) enum TagNode {
Group(Tag, Vec<TagNode>), Group(Tag, Vec<TagNode>),
Leaf(Identifier), Leaf(Identifier),
@ -489,6 +607,14 @@ fn start_artifact(gc: &mut GlobalContext, loc: Location, kind: ArtifactKind) {
gc.tags.in_artifact = Some((loc, kind)); 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 { fn artifact_type(kind: ArtifactKind) -> ArtifactType {
match kind { match kind {
ArtifactKind::Header => ArtifactType::Header, ArtifactKind::Header => ArtifactType::Header,