Compare commits

...

3 Commits

Author SHA1 Message Date
Tobias Schmitz
edd213074f
refactor: remove general api to set cell kind and add pdf.(header|data)-cell 2025-07-08 14:14:37 +02:00
Tobias Schmitz
070a0faf5c
fixup! test: table header id generation 2025-07-08 14:14:21 +02:00
Tobias Schmitz
2445bb4361
fix: table header hierarchy resolution 2025-07-08 11:28:35 +02:00
6 changed files with 243 additions and 138 deletions

View File

@ -22,7 +22,7 @@ use typst_syntax::Span;
use typst_utils::NonZeroExt;
use crate::introspection::SplitLocator;
use crate::model::{TableCellKind, TableHeaderScope};
use crate::pdf::{TableCellKind, TableHeaderScope};
/// Convert a grid to a cell grid.
#[typst_macros::time(span = elem.span())]
@ -226,7 +226,7 @@ impl ResolvableCell for Packed<TableCell> {
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 kind = cell.kind().copied().unwrap_or_default().or(kind);
let cell_stroke = cell.stroke(styles);
let stroke_overridden =

View File

@ -2,14 +2,13 @@ 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};
use crate::engine::Engine;
use crate::foundations::{
cast, dict, elem, scope, Content, Dict, NativeElement, Packed, Show, Smart,
StyleChain, TargetElem,
cast, elem, scope, Content, NativeElement, Packed, Show, Smart, StyleChain,
TargetElem,
};
use crate::html::{attr, tag, HtmlAttrs, HtmlElem, HtmlTag};
use crate::introspection::{Locatable, Locator};
@ -20,6 +19,7 @@ use crate::layout::{
TrackSizings,
};
use crate::model::Figurable;
use crate::pdf::TableCellKind;
use crate::text::LocalName;
use crate::visualize::{Paint, Stroke};
@ -811,7 +811,8 @@ pub struct TableCell {
#[fold]
pub stroke: Sides<Option<Option<Arc<Stroke>>>>,
// TODO: feature gate
#[internal]
#[synthesized]
pub kind: Smart<TableCellKind>,
/// Whether rows spanned by this cell can be placed in different pages.
@ -851,65 +852,3 @@ impl From<Content> for TableCell {
value.unpack::<Self>().unwrap_or_else(Self::new)
}
}
#[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,
}
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

@ -1,11 +1,14 @@
use std::num::NonZeroU32;
use ecow::EcoString;
use typst_macros::{cast, elem, Cast};
use typst_macros::{cast, elem, func, Cast};
use typst_utils::NonZeroExt;
use crate::diag::SourceResult;
use crate::engine::Engine;
use crate::foundations::{Content, Packed, Show, StyleChain};
use crate::foundations::{Content, NativeElement, Packed, Show, Smart, StyleChain};
use crate::introspection::Locatable;
use crate::model::TableHeaderScope;
use crate::model::TableCell;
// TODO: docs
#[elem(Locatable, Show)]
@ -210,3 +213,68 @@ impl Show for Packed<ArtifactElem> {
Ok(self.body.clone())
}
}
// TODO: feature gate
/// Explicity define this cell as a header cell.
#[func]
pub fn header_cell(
#[named]
#[default(NonZeroU32::ONE)]
level: NonZeroU32,
#[named]
#[default]
scope: TableHeaderScope,
/// The table cell.
cell: TableCell,
) -> Content {
cell.with_kind(Smart::Custom(TableCellKind::Header(level, scope)))
.pack()
}
// TODO: feature gate
/// Explicity define this cell as a data cell.
#[func]
pub fn data_cell(
/// The table cell.
cell: TableCell,
) -> Content {
cell.with_kind(Smart::Custom(TableCellKind::Data)).pack()
}
#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)]
pub enum TableCellKind {
Header(NonZeroU32, TableHeaderScope),
Footer,
#[default]
Data,
}
/// 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

@ -15,5 +15,7 @@ pub fn module() -> Module {
pdf.define_elem::<EmbedElem>();
pdf.define_elem::<PdfTagElem>();
pdf.define_elem::<ArtifactElem>();
pdf.define_func::<header_cell>();
pdf.define_func::<data_cell>();
Module::new("pdf", pdf)
}

View File

@ -181,7 +181,7 @@ pub(crate) fn handle_end(gc: &mut GlobalContext, loc: Location) {
return;
};
table_ctx.insert(cell, entry.nodes);
table_ctx.insert(&cell, entry.nodes);
return;
}
StackEntryKind::Link(_, link) => {

View File

@ -7,7 +7,8 @@ use krilla::tagging::{
};
use smallvec::SmallVec;
use typst_library::foundations::{Packed, Smart, StyleChain};
use typst_library::model::{TableCell, TableCellKind, TableHeaderScope};
use typst_library::model::TableCell;
use typst_library::pdf::{TableCellKind, TableHeaderScope};
use crate::tags::{TableId, TagNode};
@ -54,12 +55,12 @@ impl TableCtx {
}
}
pub(crate) fn insert(&mut self, cell: Packed<TableCell>, nodes: Vec<TagNode>) {
pub(crate) fn insert(&mut self, cell: &TableCell, nodes: Vec<TagNode>) {
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());
let colspan = cell.colspan(StyleChain::default());
let kind = cell.kind(StyleChain::default());
let kind = cell.kind().copied().expect("kind to be set after layouting");
// Extend the table grid to fit this cell.
let required_height = y + rowspan.get();
@ -84,7 +85,7 @@ impl TableCtx {
x: x.saturating_as(),
y: y.saturating_as(),
rowspan: rowspan.try_into().unwrap_or(NonZeroU32::MAX),
colspan: rowspan.try_into().unwrap_or(NonZeroU32::MAX),
colspan: colspan.try_into().unwrap_or(NonZeroU32::MAX),
kind,
headers: TagIdRefs::NONE,
nodes,
@ -133,7 +134,7 @@ impl TableCtx {
// Explicitly set the headers attribute for cells.
for x in 0..width {
let mut column_header = None;
let mut column_header = Vec::new();
for y in 0..height {
self.resolve_cell_headers(
(x, y),
@ -143,7 +144,7 @@ impl TableCtx {
}
}
for y in 0..height {
let mut row_header = None;
let mut row_header = Vec::new();
for x in 0..width {
self.resolve_cell_headers(
(x, y),
@ -224,7 +225,7 @@ impl TableCtx {
fn resolve_cell_headers<F>(
&mut self,
(x, y): (usize, usize),
current_header: &mut Option<(NonZeroU32, TagId)>,
current_header: &mut Vec<(NonZeroU32, TagId)>,
refers_to_dir: F,
) where
F: Fn(&TableHeaderScope) -> bool,
@ -232,26 +233,24 @@ impl TableCtx {
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;
}
}
let mut new_header = None;
if let TableCellKind::Header(level, scope) = cell.unwrap_kind() {
if refers_to_dir(&scope) {
// Remove all headers that are the same or a lower level.
while current_header.pop_if(|(l, _)| *l >= level).is_some() {}
if is_parent_header && !cell.headers.ids.contains(&cell_id) {
let tag_id = table_cell_id(table_id, cell.x, cell.y);
new_header = Some((level, tag_id));
}
}
if let Some((_, cell_id)) = current_header.last() {
if !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, cell.x, cell.y);
*current_header = Some((level, tag_id));
}
}
current_header.extend(new_header);
}
}
@ -339,21 +338,24 @@ mod tests {
#[track_caller]
fn test(table: TableCtx, exp_tag: TagNode) {
let tag = table.build_table(Vec::new());
assert_eq!(tag, exp_tag);
assert_eq!(exp_tag, tag);
}
#[track_caller]
fn table<const SIZE: usize>(cells: [TableCell; SIZE]) -> TableCtx {
let mut table = TableCtx::new(TableId(324), Some("summary".into()));
for cell in cells {
table.insert(Packed::new(cell), Vec::new());
table.insert(&cell, Vec::new());
}
table
}
#[track_caller]
fn header_cell(x: usize, y: usize, level: u32, scope: TableHeaderScope) -> TableCell {
fn header_cell(
(x, y): (usize, usize),
level: u32,
scope: TableHeaderScope,
) -> TableCell {
TableCell::new(Content::default())
.with_x(Smart::Custom(x))
.with_y(Smart::Custom(y))
@ -363,6 +365,14 @@ mod tests {
)))
}
#[track_caller]
fn footer_cell(x: usize, y: usize) -> TableCell {
TableCell::new(Content::default())
.with_x(Smart::Custom(x))
.with_y(Smart::Custom(y))
.with_kind(Smart::Custom(TableCellKind::Footer))
}
fn cell(x: usize, y: usize) -> TableCell {
TableCell::new(Content::default())
.with_x(Smart::Custom(x))
@ -370,26 +380,36 @@ mod tests {
.with_kind(Smart::Custom(TableCellKind::Data))
}
fn empty_cell(x: usize, y: usize) -> TableCell {
TableCell::new(Content::default())
.with_x(Smart::Custom(x))
.with_y(Smart::Custom(y))
.with_kind(Smart::Auto)
}
fn table_tag<const SIZE: usize>(nodes: [TagNode; SIZE]) -> TagNode {
let tag = TagKind::Table(Some("summary".into()));
TagNode::Group(tag.into(), nodes.into())
}
fn header<const SIZE: usize>(nodes: [TagNode; SIZE]) -> TagNode {
fn thead<const SIZE: usize>(nodes: [TagNode; SIZE]) -> TagNode {
TagNode::Group(TagKind::THead.into(), nodes.into())
}
fn body<const SIZE: usize>(nodes: [TagNode; SIZE]) -> TagNode {
fn tbody<const SIZE: usize>(nodes: [TagNode; SIZE]) -> TagNode {
TagNode::Group(TagKind::TBody.into(), nodes.into())
}
fn row<const SIZE: usize>(nodes: [TagNode; SIZE]) -> TagNode {
fn tfoot<const SIZE: usize>(nodes: [TagNode; SIZE]) -> TagNode {
TagNode::Group(TagKind::TFoot.into(), nodes.into())
}
fn trow<const SIZE: usize>(nodes: [TagNode; SIZE]) -> TagNode {
TagNode::Group(TagKind::TR.into(), nodes.into())
}
fn header_cell_tag<const SIZE: usize>(
x: u32,
y: u32,
fn th<const SIZE: usize>(
(x, y): (u32, u32),
scope: TableHeaderScope,
headers: [(u32, u32); SIZE],
) -> TagNode {
@ -406,7 +426,7 @@ mod tests {
)
}
fn cell_tag<const SIZE: usize>(headers: [(u32, u32); SIZE]) -> TagNode {
fn td<const SIZE: usize>(headers: [(u32, u32); SIZE]) -> TagNode {
let ids = headers
.map(|(x, y)| table_cell_id(TableId(324), x, y))
.into_iter()
@ -421,9 +441,9 @@ mod tests {
fn simple_table() {
#[rustfmt::skip]
let table = table([
header_cell(0, 0, 1, TableHeaderScope::Column),
header_cell(1, 0, 1, TableHeaderScope::Column),
header_cell(2, 0, 1, TableHeaderScope::Column),
header_cell((0, 0), 1, TableHeaderScope::Column),
header_cell((1, 0), 1, TableHeaderScope::Column),
header_cell((2, 0), 1, TableHeaderScope::Column),
cell(0, 1),
cell(1, 1),
@ -436,21 +456,21 @@ mod tests {
#[rustfmt::skip]
let tag = table_tag([
header([row([
header_cell_tag(0, 0, TableHeaderScope::Column, []),
header_cell_tag(1, 0, TableHeaderScope::Column, []),
header_cell_tag(2, 0, TableHeaderScope::Column, []),
thead([trow([
th((0, 0), TableHeaderScope::Column, []),
th((1, 0), TableHeaderScope::Column, []),
th((2, 0), TableHeaderScope::Column, []),
])]),
body([
row([
cell_tag([(0, 0)]),
cell_tag([(1, 0)]),
cell_tag([(2, 0)]),
tbody([
trow([
td([(0, 0)]),
td([(1, 0)]),
td([(2, 0)]),
]),
row([
cell_tag([(0, 0)]),
cell_tag([(1, 0)]),
cell_tag([(2, 0)]),
trow([
td([(0, 0)]),
td([(1, 0)]),
td([(2, 0)]),
]),
]),
]);
@ -462,35 +482,111 @@ mod tests {
fn header_row_and_column() {
#[rustfmt::skip]
let table = table([
header_cell(0, 0, 1, TableHeaderScope::Column),
header_cell(1, 0, 1, TableHeaderScope::Column),
header_cell(2, 0, 1, TableHeaderScope::Column),
header_cell((0, 0), 1, TableHeaderScope::Column),
header_cell((1, 0), 1, TableHeaderScope::Column),
header_cell((2, 0), 1, TableHeaderScope::Column),
header_cell(0, 1, 1, TableHeaderScope::Row),
header_cell((0, 1), 1, TableHeaderScope::Row),
cell(1, 1),
cell(2, 1),
header_cell(0, 2, 1, TableHeaderScope::Row),
header_cell((0, 2), 1, TableHeaderScope::Row),
cell(1, 2),
cell(2, 2),
]);
#[rustfmt::skip]
let tag = table_tag([
row([
header_cell_tag(0, 0, TableHeaderScope::Column, []),
header_cell_tag(1, 0, TableHeaderScope::Column, []),
header_cell_tag(2, 0, TableHeaderScope::Column, []),
trow([
th((0, 0), TableHeaderScope::Column, []),
th((1, 0), TableHeaderScope::Column, []),
th((2, 0), TableHeaderScope::Column, []),
]),
row([
header_cell_tag(0, 1, TableHeaderScope::Row, [(0, 0)]),
cell_tag([(1, 0), (0, 1)]),
cell_tag([(2, 0), (0, 1)]),
trow([
th((0, 1), TableHeaderScope::Row, [(0, 0)]),
td([(1, 0), (0, 1)]),
td([(2, 0), (0, 1)]),
]),
row([
header_cell_tag(0, 2, TableHeaderScope::Row, [(0, 0)]),
cell_tag([(1, 0), (0, 2)]),
cell_tag([(2, 0), (0, 2)]),
trow([
th((0, 2), TableHeaderScope::Row, [(0, 0)]),
td([(1, 0), (0, 2)]),
td([(2, 0), (0, 2)]),
]),
]);
test(table, tag);
}
#[test]
fn complex_tables() {
#[rustfmt::skip]
let table = table([
header_cell((0, 0), 1, TableHeaderScope::Column),
header_cell((1, 0), 1, TableHeaderScope::Column),
header_cell((2, 0), 1, TableHeaderScope::Column),
header_cell((0, 1), 2, TableHeaderScope::Column),
header_cell((1, 1), 2, TableHeaderScope::Column),
header_cell((2, 1), 2, TableHeaderScope::Column),
cell(0, 2),
empty_cell(1, 2), // the type of empty cells is inferred from the row
cell(2, 2),
header_cell((0, 3), 2, TableHeaderScope::Column),
header_cell((1, 3), 2, TableHeaderScope::Column),
empty_cell(2, 3), // the type of empty cells is inferred from the row
cell(0, 4),
cell(1, 4),
empty_cell(2, 4),
empty_cell(0, 5), // the type of empty cells is inferred from the row
footer_cell(1, 5),
footer_cell(2, 5),
]);
#[rustfmt::skip]
let tag = table_tag([
thead([
trow([
th((0, 0), TableHeaderScope::Column, []),
th((1, 0), TableHeaderScope::Column, []),
th((2, 0), TableHeaderScope::Column, []),
]),
trow([
th((0, 1), TableHeaderScope::Column, [(0, 0)]),
th((1, 1), TableHeaderScope::Column, [(1, 0)]),
th((2, 1), TableHeaderScope::Column, [(2, 0)]),
]),
]),
tbody([
trow([
td([(0, 1)]),
td([(1, 1)]),
td([(2, 1)]),
]),
]),
thead([
trow([
th((0, 3), TableHeaderScope::Column, [(0, 0)]),
th((1, 3), TableHeaderScope::Column, [(1, 0)]),
th((2, 3), TableHeaderScope::Column, [(2, 0)]),
]),
]),
tbody([
trow([
td([(0, 3)]),
td([(1, 3)]),
td([(2, 3)]),
]),
]),
tfoot([
trow([
td([(0, 3)]),
td([(1, 3)]),
td([(2, 3)]),
]),
]),
]);