summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTobias Schmitz <tobiasschmitz2001@gmail.com>2025-07-02 23:44:44 +0200
committerTobias Schmitz <tobiasschmitz2001@gmail.com>2025-07-03 18:43:17 +0200
commit50cd81ee1f65ce8fdfa1897991bb4770e671e93c (patch)
tree86b3a0868d62f141a767d0e16d26a27db45091d4
parent746926c7da8187e784120043fe93e96ebd691754 (diff)
feat: generate headers attribute table cells
- fix marking repeated headers/footers as artifacts - fix table row grouping with empty cells
-rw-r--r--Cargo.lock5
-rw-r--r--crates/typst-library/src/layout/grid/resolve.rs23
-rw-r--r--crates/typst-library/src/model/table.rs68
-rw-r--r--crates/typst-library/src/pdf/accessibility.rs12
-rw-r--r--crates/typst-pdf/src/tags.rs258
5 files changed, 277 insertions, 89 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 0ad90fb3..4c92cf82 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1384,6 +1384,7 @@ dependencies = [
"rustybuzz",
"siphasher",
"skrifa",
+ "smallvec",
"subsetter",
"tiny-skia-path",
"xmp-writer",
@@ -2449,9 +2450,9 @@ dependencies = [
[[package]]
name = "smallvec"
-version = "1.13.2"
+version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "spin"
diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs
index 0de5a6b9..49f9e0ed 100644
--- a/crates/typst-library/src/layout/grid/resolve.rs
+++ b/crates/typst-library/src/layout/grid/resolve.rs
@@ -22,7 +22,7 @@ use typst_syntax::Span;
use typst_utils::NonZeroExt;
use crate::introspection::SplitLocator;
-use crate::model::TableCellKind;
+use crate::model::{TableCellKind, TableHeaderScope};
/// Convert a grid to a cell grid.
#[typst_macros::time(span = elem.span())]
@@ -1213,11 +1213,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// a non-empty row.
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 {
- ResolvableGridChild::Header { repeat, level, span, items, .. } => {
- cell_kind = Smart::Custom(TableCellKind::Header);
+ ResolvableGridChild::Header { repeat, level, span, items } => {
+ cell_kind =
+ Smart::Custom(TableCellKind::Header(level, TableHeaderScope::Column));
row_group_data = Some(RowGroupData {
range: None,
@@ -1245,7 +1247,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
(Some(items), None)
}
- ResolvableGridChild::Footer { repeat, span, items, .. } => {
+ ResolvableGridChild::Footer { repeat, span, items } => {
if footer.is_some() {
bail!(span, "cannot have more than one footer");
}
@@ -1270,6 +1272,8 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
(Some(items), None)
}
ResolvableGridChild::Item(item) => {
+ cell_kind = Smart::Custom(TableCellKind::Data);
+
if matches!(item, ResolvableGridItem::Cell(_)) {
*at_least_one_cell = true;
}
@@ -1556,8 +1560,11 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// 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,
+ RowGroupKind::Header => TableCellKind::Header(
+ NonZeroU32::ONE,
+ TableHeaderScope::default(),
+ ),
+ RowGroupKind::Footer => TableCellKind::Footer,
};
resolved_cells[*local_auto_index] =
Some(Entry::Cell(self.resolve_cell(
@@ -1691,8 +1698,6 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
y,
1,
Span::detached(),
- // FIXME: empty cells will within header and footer rows
- // will prevent row group tags.
Smart::Auto,
)?))
}
diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs
index b10bfb00..f8fe7691 100644
--- a/crates/typst-library/src/model/table.rs
+++ b/crates/typst-library/src/model/table.rs
@@ -8,8 +8,8 @@ use typst_utils::NonZeroExt;
use crate::diag::{bail, HintedStrResult, HintedString, SourceResult};
use crate::engine::Engine;
use crate::foundations::{
- cast, elem, scope, Content, NativeElement, Packed, Show, Smart, StyleChain,
- TargetElem,
+ cast, dict, elem, scope, Content, Dict, NativeElement, Packed, Show, Smart,
+ StyleChain, TargetElem,
};
use crate::html::{attr, tag, HtmlAttrs, HtmlElem, HtmlTag};
use crate::introspection::{Locatable, Locator};
@@ -814,9 +814,6 @@ pub struct TableCell {
// 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.
/// When equal to `{auto}`, a cell spanning only fixed-size rows 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 TableCellKind {
+ Header(NonZeroU32, TableHeaderScope),
+ Footer,
+ #[default]
+ 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,
}
-#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)]
-pub enum TableCellKind {
- Header,
- Footer,
- #[default]
- Data,
+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,
+ }
+ }
}
diff --git a/crates/typst-library/src/pdf/accessibility.rs b/crates/typst-library/src/pdf/accessibility.rs
index a5df131d..7ec52f8c 100644
--- a/crates/typst-library/src/pdf/accessibility.rs
+++ b/crates/typst-library/src/pdf/accessibility.rs
@@ -5,6 +5,7 @@ use crate::diag::SourceResult;
use crate::engine::Engine;
use crate::foundations::{Content, Packed, Show, StyleChain};
use crate::introspection::Locatable;
+use crate::model::TableHeaderScope;
// TODO: docs
#[elem(Locatable, Show)]
@@ -177,17 +178,6 @@ pub enum ListNumbering {
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.
/// TODO: maybe generalize this and use it to mark html elements with `aria-hidden="true"`?
#[elem(Locatable, Show)]
diff --git a/crates/typst-pdf/src/tags.rs b/crates/typst-pdf/src/tags.rs
index 815b752e..9f49024f 100644
--- a/crates/typst-pdf/src/tags.rs
+++ b/crates/typst-pdf/src/tags.rs
@@ -5,8 +5,8 @@ use ecow::EcoString;
use krilla::page::Page;
use krilla::surface::Surface;
use krilla::tagging::{
- ArtifactType, ContentTag, Identifier, Node, SpanTag, TableCellSpan, TableDataCell,
- TableHeaderCell, Tag, TagBuilder, TagGroup, TagKind, TagTree,
+ ArtifactType, ContentTag, Identifier, Node, SpanTag, TableCellHeaders, TableCellSpan,
+ TableDataCell, TableHeaderCell, Tag, TagBuilder, TagGroup, TagId, TagKind, TagTree,
};
use typst_library::foundations::{Content, LinkMarker, Packed, Smart, StyleChain};
use typst_library::introspection::Location;
@@ -27,12 +27,22 @@ pub(crate) struct Tags {
/// A list of placeholders corresponding to a [`TagNode::Placeholder`].
pub(crate) placeholders: Vec<OnceCell<Node>>,
pub(crate) in_artifact: Option<(Location, ArtifactKind)>,
+ /// Used to group multiple link annotations using quad points.
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.
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) loc: Location,
pub(crate) kind: StackEntryKind,
@@ -125,6 +135,7 @@ impl OutlineCtx {
}
pub(crate) struct TableCtx {
+ id: TableId,
table: Packed<TableElem>,
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> {
if let Self::Cell(v) = self {
Some(v)
@@ -157,25 +176,56 @@ impl GridCell {
#[derive(Clone)]
struct TableCtxCell {
+ x: u32,
+ y: u32,
rowspan: NonZeroUsize,
colspan: NonZeroUsize,
- kind: TableCellKind,
- header_scope: Smart<TableHeaderScope>,
+ kind: Smart<TableCellKind>,
+ headers: TableCellHeaders,
nodes: Vec<TagNode>,
}
+impl TableCtxCell {
+ fn unwrap_kind(&self) -> TableCellKind {
+ self.kind.unwrap_or_else(|| unreachable!())
+ }
+}
+
impl TableCtx {
- fn new(table: Packed<TableElem>) -> Self {
- Self { table: table.clone(), rows: Vec::new() }
+ fn new(id: TableId, table: Packed<TableElem>) -> Self {
+ 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 {
let x = cell.x(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 };
- let Some(cell) = row.get(x) else { return false };
- !matches!(cell, GridCell::Missing)
+ fn resolve_cell<'a>(&'a self, cell: &'a GridCell) -> Option<&'a TableCtxCell> {
+ match cell {
+ 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>) {
@@ -184,15 +234,6 @@ impl TableCtx {
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());
-
- // 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.
let required_height = y + rowspan.get();
@@ -213,39 +254,80 @@ impl TableCtx {
}
}
- self.rows[y][x] =
- GridCell::Cell(TableCtxCell { rowspan, colspan, kind, header_scope, nodes });
+ self.rows[y][x] = GridCell::Cell(TableCtxCell {
+ 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
// 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
// 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())
.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,
- })
+ .filter_map(|cell| self.resolve_cell(cell))
.map(|cell| cell.kind)
- .reduce(|a, b| {
- if a != b {
- mixed_row_kinds = true;
+ .fold(Smart::Auto, |a, b| {
+ if let Smart::Custom(TableCellKind::Header(_, scope)) = b {
+ 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)
})
.collect::<Vec<_>>();
- let Some(mut chunk_kind) = row_kinds.first().copied() else {
- return nodes;
- };
+ // Fixup all missing cell kinds.
+ 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();
for (row, row_kind) in self.rows.into_iter().zip(row_kinds) {
let row_nodes = row
@@ -253,38 +335,44 @@ impl TableCtx {
.filter_map(|cell| {
let cell = cell.into_cell()?;
let span = TableCellSpan {
- rows: cell.rowspan.get() as i32,
- cols: cell.colspan.get() as i32,
+ rows: cell.rowspan.try_into().unwrap(),
+ cols: cell.colspan.try_into().unwrap(),
};
- 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))
+ let tag = match cell.unwrap_kind() {
+ TableCellKind::Header(_, scope) => {
+ let id = table_cell_id(self.id, cell.x, cell.y);
+ let scope = table_header_scope(scope);
+ TagKind::TH(
+ TableHeaderCell::new(scope)
+ .with_span(span)
+ .with_headers(cell.headers),
+ )
+ .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();
let row = TagNode::Group(TagKind::TR.into(), row_nodes);
// Push the `TR` tags directly.
- if mixed_row_kinds {
+ if !gen_row_groups {
nodes.push(row);
continue;
}
// Generate row groups.
- if row_kind != chunk_kind {
+ if !should_group_rows(chunk_kind, row_kind) {
let tag = match chunk_kind {
- TableCellKind::Header => TagKind::THead,
+ TableCellKind::Header(..) => TagKind::THead,
TableCellKind::Footer => TagKind::TFoot,
TableCellKind::Data => TagKind::TBody,
};
@@ -297,7 +385,7 @@ impl TableCtx {
if !row_chunk.is_empty() {
let tag = match chunk_kind {
- TableCellKind::Header => TagKind::THead,
+ TableCellKind::Header(..) => TagKind::THead,
TableCellKind::Footer => TagKind::TFoot,
TableCellKind::Data => TagKind::TBody,
};
@@ -306,6 +394,56 @@ impl TableCtx {
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)]
@@ -317,9 +455,6 @@ pub(crate) enum TagNode {
Placeholder(Placeholder),
}
-#[derive(Clone, Copy, PartialEq, Eq, Hash)]
-pub(crate) struct LinkId(u32);
-
#[derive(Clone, Copy)]
pub(crate) struct Placeholder(usize);
@@ -332,6 +467,7 @@ impl Tags {
tree: Vec::new(),
link_id: LinkId(0),
+ table_id: TableId(0),
}
}
@@ -400,6 +536,11 @@ impl Tags {
self.link_id.0 += 1;
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.
@@ -530,7 +671,9 @@ pub(crate) fn handle_start(gc: &mut GlobalContext, elem: &Content) {
} else if let Some(_) = elem.to_packed::<FigureCaption>() {
TagKind::Caption.into()
} 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;
} else if let Some(cell) = elem.to_packed::<TableCell>() {
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
// it's semantic structure.
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);
} else {
push_stack(gc, loc, StackEntryKind::TableCell(cell.clone()));