feat: generate headers attribute table cells

- fix marking repeated headers/footers as artifacts
- fix table row grouping with empty cells
This commit is contained in:
Tobias Schmitz 2025-07-02 23:44:44 +02:00
parent 746926c7da
commit 50cd81ee1f
No known key found for this signature in database
5 changed files with 279 additions and 91 deletions

5
Cargo.lock generated
View File

@ -1384,6 +1384,7 @@ dependencies = [
"rustybuzz", "rustybuzz",
"siphasher", "siphasher",
"skrifa", "skrifa",
"smallvec",
"subsetter", "subsetter",
"tiny-skia-path", "tiny-skia-path",
"xmp-writer", "xmp-writer",
@ -2449,9 +2450,9 @@ dependencies = [
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.13.2" version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]] [[package]]
name = "spin" name = "spin"

View File

@ -22,7 +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; use crate::model::{TableCellKind, TableHeaderScope};
/// 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())]
@ -1213,11 +1213,13 @@ 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; // The cell kind is currently only used for tagged PDF.
let cell_kind;
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); cell_kind =
Smart::Custom(TableCellKind::Header(level, TableHeaderScope::Column));
row_group_data = Some(RowGroupData { row_group_data = Some(RowGroupData {
range: None, range: None,
@ -1245,7 +1247,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
(Some(items), None) (Some(items), None)
} }
ResolvableGridChild::Footer { repeat, span, items, .. } => { ResolvableGridChild::Footer { repeat, span, items } => {
if footer.is_some() { if footer.is_some() {
bail!(span, "cannot have more than one footer"); bail!(span, "cannot have more than one footer");
} }
@ -1270,6 +1272,8 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
(Some(items), None) (Some(items), None)
} }
ResolvableGridChild::Item(item) => { ResolvableGridChild::Item(item) => {
cell_kind = Smart::Custom(TableCellKind::Data);
if matches!(item, ResolvableGridItem::Cell(_)) { if matches!(item, ResolvableGridItem::Cell(_)) {
*at_least_one_cell = true; *at_least_one_cell = true;
} }
@ -1556,8 +1560,11 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// 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 { let kind = match row_group.kind {
RowGroupKind::Header => TableCellKind::Header, RowGroupKind::Header => TableCellKind::Header(
RowGroupKind::Footer => TableCellKind::Header, NonZeroU32::ONE,
TableHeaderScope::default(),
),
RowGroupKind::Footer => TableCellKind::Footer,
}; };
resolved_cells[*local_auto_index] = resolved_cells[*local_auto_index] =
Some(Entry::Cell(self.resolve_cell( Some(Entry::Cell(self.resolve_cell(
@ -1691,8 +1698,6 @@ 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, Smart::Auto,
)?)) )?))
} }

View File

@ -8,8 +8,8 @@ use typst_utils::NonZeroExt;
use crate::diag::{bail, HintedStrResult, HintedString, SourceResult}; use crate::diag::{bail, HintedStrResult, HintedString, SourceResult};
use crate::engine::Engine; use crate::engine::Engine;
use crate::foundations::{ use crate::foundations::{
cast, elem, scope, Content, NativeElement, Packed, Show, Smart, StyleChain, cast, dict, elem, scope, Content, Dict, NativeElement, Packed, Show, Smart,
TargetElem, StyleChain, TargetElem,
}; };
use crate::html::{attr, tag, HtmlAttrs, HtmlElem, HtmlTag}; use crate::html::{attr, tag, HtmlAttrs, HtmlElem, HtmlTag};
use crate::introspection::{Locatable, Locator}; use crate::introspection::{Locatable, Locator};
@ -814,9 +814,6 @@ pub struct TableCell {
// TODO: feature gate // TODO: feature gate
pub kind: Smart<TableCellKind>, 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
@ -855,17 +852,64 @@ impl From<Content> for TableCell {
} }
} }
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] #[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)]
pub enum TableHeaderScope {
Both,
Column,
Row,
}
#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)]
pub enum TableCellKind { pub enum TableCellKind {
Header, Header(NonZeroU32, TableHeaderScope),
Footer, Footer,
#[default] #[default]
Data, Data,
} }
cast! {
TableCellKind,
self => match self {
Self::Header(level, scope) => dict! { "level" => level, "scope" => scope }.into_value(),
Self::Footer => "footer".into_value(),
Self::Data => "data".into_value(),
},
"header" => Self::Header(NonZeroU32::ONE, TableHeaderScope::default()),
"footer" => Self::Footer,
"data" => Self::Data,
mut dict: Dict => {
// TODO: have a `pdf.header` function instead?
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
enum HeaderKind {
Header,
}
dict.take("kind")?.cast::<HeaderKind>()?;
let level = dict.take("level").ok().map(|v| v.cast()).transpose()?;
let scope = dict.take("scope").ok().map(|v| v.cast()).transpose()?;
dict.finish(&["kind", "level", "scope"])?;
Self::Header(level.unwrap_or(NonZeroU32::ONE), scope.unwrap_or_default())
},
}
/// The scope of a table header cell.
#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)]
pub enum TableHeaderScope {
/// The header cell refers to both the row and the column.
Both,
/// The header cell refers to the column.
#[default]
Column,
/// The header cell refers to the row.
Row,
}
impl TableHeaderScope {
pub fn refers_to_column(&self) -> bool {
match self {
TableHeaderScope::Both => true,
TableHeaderScope::Column => true,
TableHeaderScope::Row => false,
}
}
pub fn refers_to_row(&self) -> bool {
match self {
TableHeaderScope::Both => true,
TableHeaderScope::Column => false,
TableHeaderScope::Row => true,
}
}
}

View File

@ -5,6 +5,7 @@ use crate::diag::SourceResult;
use crate::engine::Engine; use crate::engine::Engine;
use crate::foundations::{Content, Packed, Show, StyleChain}; use crate::foundations::{Content, Packed, Show, StyleChain};
use crate::introspection::Locatable; use crate::introspection::Locatable;
use crate::model::TableHeaderScope;
// TODO: docs // TODO: docs
#[elem(Locatable, Show)] #[elem(Locatable, Show)]
@ -177,17 +178,6 @@ pub enum ListNumbering {
UpperAlpha, UpperAlpha,
} }
/// The scope of a table header cell.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum TableHeaderScope {
/// The header cell refers to the row.
Row,
/// The header cell refers to the column.
Column,
/// The header cell refers to both the row and the column.
Both,
}
/// Mark content as a PDF artifact. /// Mark content as a PDF artifact.
/// TODO: maybe generalize this and use it to mark html elements with `aria-hidden="true"`? /// TODO: maybe generalize this and use it to mark html elements with `aria-hidden="true"`?
#[elem(Locatable, Show)] #[elem(Locatable, Show)]

View File

@ -5,8 +5,8 @@ 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, TableCellHeaders, TableCellSpan,
TableHeaderCell, Tag, TagBuilder, TagGroup, TagKind, TagTree, TableDataCell, TableHeaderCell, Tag, TagBuilder, TagGroup, TagId, TagKind, TagTree,
}; };
use typst_library::foundations::{Content, LinkMarker, Packed, Smart, StyleChain}; use typst_library::foundations::{Content, LinkMarker, Packed, Smart, StyleChain};
use typst_library::introspection::Location; use typst_library::introspection::Location;
@ -27,12 +27,22 @@ pub(crate) struct Tags {
/// A list of placeholders corresponding to a [`TagNode::Placeholder`]. /// A list of placeholders corresponding to a [`TagNode::Placeholder`].
pub(crate) placeholders: Vec<OnceCell<Node>>, pub(crate) placeholders: Vec<OnceCell<Node>>,
pub(crate) in_artifact: Option<(Location, ArtifactKind)>, pub(crate) in_artifact: Option<(Location, ArtifactKind)>,
/// Used to group multiple link annotations using quad points.
pub(crate) link_id: LinkId, pub(crate) link_id: LinkId,
/// Used to generate IDs referenced in table `Headers` attributes.
/// The IDs must be document wide unique.
pub(crate) table_id: TableId,
/// The output. /// The output.
pub(crate) tree: Vec<TagNode>, pub(crate) tree: Vec<TagNode>,
} }
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct TableId(u32);
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct LinkId(u32);
pub(crate) struct StackEntry { pub(crate) struct StackEntry {
pub(crate) loc: Location, pub(crate) loc: Location,
pub(crate) kind: StackEntryKind, pub(crate) kind: StackEntryKind,
@ -125,6 +135,7 @@ impl OutlineCtx {
} }
pub(crate) struct TableCtx { pub(crate) struct TableCtx {
id: TableId,
table: Packed<TableElem>, table: Packed<TableElem>,
rows: Vec<Vec<GridCell>>, rows: Vec<Vec<GridCell>>,
} }
@ -146,6 +157,14 @@ impl GridCell {
} }
} }
fn as_cell_mut(&mut self) -> Option<&mut TableCtxCell> {
if let Self::Cell(v) = self {
Some(v)
} else {
None
}
}
fn into_cell(self) -> Option<TableCtxCell> { fn into_cell(self) -> Option<TableCtxCell> {
if let Self::Cell(v) = self { if let Self::Cell(v) = self {
Some(v) Some(v)
@ -157,25 +176,56 @@ impl GridCell {
#[derive(Clone)] #[derive(Clone)]
struct TableCtxCell { struct TableCtxCell {
x: u32,
y: u32,
rowspan: NonZeroUsize, rowspan: NonZeroUsize,
colspan: NonZeroUsize, colspan: NonZeroUsize,
kind: TableCellKind, kind: Smart<TableCellKind>,
header_scope: Smart<TableHeaderScope>, headers: TableCellHeaders,
nodes: Vec<TagNode>, nodes: Vec<TagNode>,
} }
impl TableCtxCell {
fn unwrap_kind(&self) -> TableCellKind {
self.kind.unwrap_or_else(|| unreachable!())
}
}
impl TableCtx { impl TableCtx {
fn new(table: Packed<TableElem>) -> Self { fn new(id: TableId, table: Packed<TableElem>) -> Self {
Self { table: table.clone(), rows: Vec::new() } Self { id, table: table.clone(), rows: Vec::new() }
}
fn get(&self, x: usize, y: usize) -> Option<&TableCtxCell> {
let cell = self.rows.get(y)?.get(x)?;
self.resolve_cell(cell)
}
fn get_mut(&mut self, x: usize, y: usize) -> Option<&mut TableCtxCell> {
let cell = self.rows.get_mut(y)?.get_mut(x)?;
match cell {
GridCell::Cell(cell) => {
// HACK: Workaround for the second mutable borrow when resolving
// the spanned cell.
Some(unsafe { std::mem::transmute(cell) })
}
&mut GridCell::Spanned(x, y) => self.rows[y][x].as_cell_mut(),
GridCell::Missing => None,
}
} }
fn contains(&self, cell: &Packed<TableCell>) -> bool { fn contains(&self, cell: &Packed<TableCell>) -> bool {
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!());
self.get(x, y).is_some()
}
let Some(row) = self.rows.get(y) else { return false }; fn resolve_cell<'a>(&'a self, cell: &'a GridCell) -> Option<&'a TableCtxCell> {
let Some(cell) = row.get(x) else { return false }; match cell {
!matches!(cell, GridCell::Missing) GridCell::Cell(cell) => Some(cell),
&GridCell::Spanned(x, y) => self.rows[y][x].as_cell(),
GridCell::Missing => None,
}
} }
fn insert(&mut self, cell: Packed<TableCell>, nodes: Vec<TagNode>) { fn insert(&mut self, cell: Packed<TableCell>, nodes: Vec<TagNode>) {
@ -184,15 +234,6 @@ impl TableCtx {
let rowspan = cell.rowspan(StyleChain::default()); let rowspan = cell.rowspan(StyleChain::default());
let colspan = cell.colspan(StyleChain::default()); let colspan = cell.colspan(StyleChain::default());
let kind = cell.kind(StyleChain::default()); let kind = cell.kind(StyleChain::default());
let header_scope = cell.header_scope(StyleChain::default());
// 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,
};
// Extend the table grid to fit this cell. // Extend the table grid to fit this cell.
let required_height = y + rowspan.get(); let required_height = y + rowspan.get();
@ -213,39 +254,80 @@ impl TableCtx {
} }
} }
self.rows[y][x] = self.rows[y][x] = GridCell::Cell(TableCtxCell {
GridCell::Cell(TableCtxCell { rowspan, colspan, kind, header_scope, nodes }); x: x as u32,
y: y as u32,
rowspan,
colspan,
kind,
headers: TableCellHeaders::NONE,
nodes,
});
} }
fn build_table(self, mut nodes: Vec<TagNode>) -> Vec<TagNode> { fn build_table(mut 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.
if self.rows.is_empty() {
return nodes;
}
let height = self.rows.len();
let width = self.rows[0].len();
// Only generate row groups such as `THead`, `TFoot`, and `TBody` if // Only generate row groups such as `THead`, `TFoot`, and `TBody` if
// there are no rows with mixed cell kinds. // there are no rows with mixed cell kinds.
let mut mixed_row_kinds = false; let mut gen_row_groups = true;
let row_kinds = (self.rows.iter()) let row_kinds = (self.rows.iter())
.map(|row| { .map(|row| {
row.iter() row.iter()
.filter_map(|cell| match cell { .filter_map(|cell| self.resolve_cell(cell))
GridCell::Cell(cell) => Some(cell),
&GridCell::Spanned(x, y) => self.rows[y][x].as_cell(),
GridCell::Missing => None,
})
.map(|cell| cell.kind) .map(|cell| cell.kind)
.reduce(|a, b| { .fold(Smart::Auto, |a, b| {
if a != b { if let Smart::Custom(TableCellKind::Header(_, scope)) = b {
mixed_row_kinds = true; gen_row_groups &= scope == TableHeaderScope::Column;
} }
a if let (Smart::Custom(a), Smart::Custom(b)) = (a, b) {
gen_row_groups &= a == b;
}
a.or(b)
}) })
.unwrap_or(TableCellKind::Data) .unwrap_or(TableCellKind::Data)
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let Some(mut chunk_kind) = row_kinds.first().copied() else { // Fixup all missing cell kinds.
return nodes; for (row, row_kind) in self.rows.iter_mut().zip(row_kinds.iter().copied()) {
}; let default_kind =
if gen_row_groups { row_kind } else { TableCellKind::Data };
for cell in row.iter_mut() {
let Some(cell) = cell.as_cell_mut() else { continue };
cell.kind = cell.kind.or(Smart::Custom(default_kind));
}
}
// Explicitly set the headers attribute for cells.
for x in 0..width {
let mut column_header = None;
for y in 0..height {
self.resolve_cell_headers(
(x, y),
&mut column_header,
TableHeaderScope::refers_to_column,
);
}
}
for y in 0..height {
let mut row_header = None;
for x in 0..width {
self.resolve_cell_headers(
(x, y),
&mut row_header,
TableHeaderScope::refers_to_row,
);
}
}
let mut chunk_kind = row_kinds[0];
let mut row_chunk = Vec::new(); let mut row_chunk = Vec::new();
for (row, row_kind) in self.rows.into_iter().zip(row_kinds) { for (row, row_kind) in self.rows.into_iter().zip(row_kinds) {
let row_nodes = row let row_nodes = row
@ -253,38 +335,44 @@ impl TableCtx {
.filter_map(|cell| { .filter_map(|cell| {
let cell = cell.into_cell()?; let cell = cell.into_cell()?;
let span = TableCellSpan { let span = TableCellSpan {
rows: cell.rowspan.get() as i32, rows: cell.rowspan.try_into().unwrap(),
cols: cell.colspan.get() as i32, cols: cell.colspan.try_into().unwrap(),
}; };
let tag = match cell.kind { let tag = match cell.unwrap_kind() {
TableCellKind::Header => { TableCellKind::Header(_, scope) => {
let scope = match cell.header_scope { let id = table_cell_id(self.id, cell.x, cell.y);
Smart::Custom(scope) => table_header_scope(scope), let scope = table_header_scope(scope);
Smart::Auto => krilla::tagging::TableHeaderScope::Column, TagKind::TH(
}; TableHeaderCell::new(scope)
TagKind::TH(TableHeaderCell::new(scope).with_span(span)) .with_span(span)
} .with_headers(cell.headers),
TableCellKind::Footer | TableCellKind::Data => { )
TagKind::TD(TableDataCell::new().with_span(span)) .with_id(Some(id))
} }
TableCellKind::Footer | TableCellKind::Data => TagKind::TD(
TableDataCell::new()
.with_span(span)
.with_headers(cell.headers),
)
.into(),
}; };
Some(TagNode::Group(tag.into(), cell.nodes)) Some(TagNode::Group(tag, cell.nodes))
}) })
.collect(); .collect();
let row = TagNode::Group(TagKind::TR.into(), row_nodes); let row = TagNode::Group(TagKind::TR.into(), row_nodes);
// Push the `TR` tags directly. // Push the `TR` tags directly.
if mixed_row_kinds { if !gen_row_groups {
nodes.push(row); nodes.push(row);
continue; continue;
} }
// Generate row groups. // Generate row groups.
if row_kind != chunk_kind { if !should_group_rows(chunk_kind, row_kind) {
let tag = match chunk_kind { let tag = match chunk_kind {
TableCellKind::Header => TagKind::THead, TableCellKind::Header(..) => TagKind::THead,
TableCellKind::Footer => TagKind::TFoot, TableCellKind::Footer => TagKind::TFoot,
TableCellKind::Data => TagKind::TBody, TableCellKind::Data => TagKind::TBody,
}; };
@ -297,7 +385,7 @@ impl TableCtx {
if !row_chunk.is_empty() { if !row_chunk.is_empty() {
let tag = match chunk_kind { let tag = match chunk_kind {
TableCellKind::Header => TagKind::THead, TableCellKind::Header(..) => TagKind::THead,
TableCellKind::Footer => TagKind::TFoot, TableCellKind::Footer => TagKind::TFoot,
TableCellKind::Data => TagKind::TBody, TableCellKind::Data => TagKind::TBody,
}; };
@ -306,6 +394,56 @@ impl TableCtx {
nodes nodes
} }
fn resolve_cell_headers<F>(
&mut self,
(x, y): (usize, usize),
current_header: &mut Option<(NonZeroU32, TagId)>,
refers_to_dir: F,
) where
F: Fn(&TableHeaderScope) -> bool,
{
let table_id = self.id;
let Some(cell) = self.get_mut(x, y) else { return };
if let Some((prev_level, cell_id)) = current_header.clone() {
// The `Headers` attribute is also set for parent headers.
let mut is_parent_header = true;
if let TableCellKind::Header(level, scope) = cell.unwrap_kind() {
if refers_to_dir(&scope) {
is_parent_header = prev_level < level;
}
}
if is_parent_header && !cell.headers.ids.contains(&cell_id) {
cell.headers.ids.push(cell_id.clone());
}
}
if let TableCellKind::Header(level, scope) = cell.unwrap_kind() {
if refers_to_dir(&scope) {
let tag_id = table_cell_id(table_id, x as u32, y as u32);
*current_header = Some((level, tag_id));
}
}
}
}
fn should_group_rows(a: TableCellKind, b: TableCellKind) -> bool {
match (a, b) {
(TableCellKind::Header(..), TableCellKind::Header(..)) => true,
(TableCellKind::Footer, TableCellKind::Footer) => true,
(TableCellKind::Data, TableCellKind::Data) => true,
(_, _) => false,
}
}
fn table_cell_id(table_id: TableId, x: u32, y: u32) -> TagId {
let mut bytes = [0; 12];
bytes[0..4].copy_from_slice(&table_id.0.to_ne_bytes());
bytes[4..8].copy_from_slice(&x.to_ne_bytes());
bytes[8..12].copy_from_slice(&y.to_ne_bytes());
TagId::from_bytes(&bytes)
} }
#[derive(Clone)] #[derive(Clone)]
@ -317,9 +455,6 @@ pub(crate) enum TagNode {
Placeholder(Placeholder), Placeholder(Placeholder),
} }
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct LinkId(u32);
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub(crate) struct Placeholder(usize); pub(crate) struct Placeholder(usize);
@ -332,6 +467,7 @@ impl Tags {
tree: Vec::new(), tree: Vec::new(),
link_id: LinkId(0), link_id: LinkId(0),
table_id: TableId(0),
} }
} }
@ -400,6 +536,11 @@ impl Tags {
self.link_id.0 += 1; self.link_id.0 += 1;
self.link_id self.link_id
} }
fn next_table_id(&mut self) -> TableId {
self.table_id.0 += 1;
self.table_id
}
} }
/// Automatically calls [`Surface::end_tagged`] when dropped. /// Automatically calls [`Surface::end_tagged`] when dropped.
@ -530,7 +671,9 @@ pub(crate) fn handle_start(gc: &mut GlobalContext, elem: &Content) {
} else if let Some(_) = elem.to_packed::<FigureCaption>() { } else if let Some(_) = elem.to_packed::<FigureCaption>() {
TagKind::Caption.into() TagKind::Caption.into()
} else if let Some(table) = elem.to_packed::<TableElem>() { } else if let Some(table) = elem.to_packed::<TableElem>() {
push_stack(gc, loc, StackEntryKind::Table(TableCtx::new(table.clone()))); let table_id = gc.tags.next_table_id();
let ctx = TableCtx::new(table_id, table.clone());
push_stack(gc, loc, StackEntryKind::Table(ctx));
return; return;
} else if let Some(cell) = elem.to_packed::<TableCell>() { } else if let Some(cell) = elem.to_packed::<TableCell>() {
let parent = gc.tags.stack.last_mut().expect("table"); let parent = gc.tags.stack.last_mut().expect("table");
@ -543,6 +686,11 @@ pub(crate) fn handle_start(gc: &mut GlobalContext, elem: &Content) {
// semantic meaning in the tag tree, which doesn't use page breaks for // semantic meaning in the tag tree, which doesn't use page breaks for
// it's semantic structure. // it's semantic structure.
if table_ctx.contains(cell) { if table_ctx.contains(cell) {
// TODO: currently the first layouted cell is picked to be part of
// the tag tree, for repeating footers this will be the cell on the
// first page. Maybe it should be the cell on the last page, but that
// would require more changes in the layouting code, or a pre-pass
// on the frames to figure out if there are other footers following.
start_artifact(gc, loc, ArtifactKind::Other); start_artifact(gc, loc, ArtifactKind::Other);
} else { } else {
push_stack(gc, loc, StackEntryKind::TableCell(cell.clone())); push_stack(gc, loc, StackEntryKind::TableCell(cell.clone()));