Repeatable Table Footers [More Flexible Tables Pt.6a] (#3577)

Co-authored-by: Laurenz <laurmaedje@gmail.com>
This commit is contained in:
PgBiel 2024-03-09 12:48:48 -03:00 committed by GitHub
parent d927974bb1
commit 639a8d0dc0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1007 additions and 182 deletions

View File

@ -248,6 +248,40 @@ pub(super) struct Header {
pub(super) end: usize, pub(super) end: usize,
} }
/// A repeatable grid footer. Stops at the last row.
pub(super) struct Footer {
/// The first row included in this footer.
pub(super) start: usize,
}
/// A possibly repeatable grid object.
/// It still exists even when not repeatable, but must not have additional
/// considerations by grid layout, other than for consistency (such as making
/// a certain group of rows unbreakable).
pub(super) enum Repeatable<T> {
Repeated(T),
NotRepeated(T),
}
impl<T> Repeatable<T> {
/// Gets the value inside this repeatable, regardless of whether
/// it repeats.
pub(super) fn unwrap(&self) -> &T {
match self {
Self::Repeated(repeated) => repeated,
Self::NotRepeated(not_repeated) => not_repeated,
}
}
/// Returns `Some` if the value is repeated, `None` otherwise.
pub(super) fn as_repeated(&self) -> Option<&T> {
match self {
Self::Repeated(repeated) => Some(repeated),
Self::NotRepeated(_) => None,
}
}
}
/// A grid item, possibly affected by automatic cell positioning. Can be either /// A grid item, possibly affected by automatic cell positioning. Can be either
/// a line or a cell. /// a line or a cell.
pub enum ResolvableGridItem<T: ResolvableCell> { pub enum ResolvableGridItem<T: ResolvableCell> {
@ -284,6 +318,7 @@ pub enum ResolvableGridItem<T: ResolvableCell> {
/// Any grid child, which can be either a header or an item. /// Any grid child, which can be either a header or an item.
pub enum ResolvableGridChild<T: ResolvableCell, I> { pub enum ResolvableGridChild<T: ResolvableCell, I> {
Header { repeat: bool, span: Span, items: I }, Header { repeat: bool, span: Span, items: I },
Footer { repeat: bool, span: Span, items: I },
Item(ResolvableGridItem<T>), Item(ResolvableGridItem<T>),
} }
@ -340,7 +375,9 @@ pub struct CellGrid {
/// Contains up to 'rows_without_gutter.len() + 1' vectors of lines. /// Contains up to 'rows_without_gutter.len() + 1' vectors of lines.
pub(super) hlines: Vec<Vec<Line>>, pub(super) hlines: Vec<Vec<Line>>,
/// The repeatable header of this grid. /// The repeatable header of this grid.
pub(super) header: Option<Header>, pub(super) header: Option<Repeatable<Header>>,
/// The repeatable footer of this grid.
pub(super) footer: Option<Repeatable<Footer>>,
/// Whether this grid has gutters. /// Whether this grid has gutters.
pub(super) has_gutter: bool, pub(super) has_gutter: bool,
} }
@ -353,7 +390,7 @@ impl CellGrid {
cells: impl IntoIterator<Item = Cell>, cells: impl IntoIterator<Item = Cell>,
) -> Self { ) -> Self {
let entries = cells.into_iter().map(Entry::Cell).collect(); let entries = cells.into_iter().map(Entry::Cell).collect();
Self::new_internal(tracks, gutter, vec![], vec![], None, entries) Self::new_internal(tracks, gutter, vec![], vec![], None, None, entries)
} }
/// Resolves and positions all cells in the grid before creating it. /// Resolves and positions all cells in the grid before creating it.
@ -398,6 +435,11 @@ impl CellGrid {
let mut header: Option<Header> = None; let mut header: Option<Header> = None;
let mut repeat_header = false; let mut repeat_header = false;
// Stores where the footer is supposed to end, its span, and the
// actual footer structure.
let mut footer: Option<(usize, Span, Footer)> = None;
let mut repeat_footer = false;
// Resolve the breakability of a cell, based on whether or not it spans // Resolve the breakability of a cell, based on whether or not it spans
// an auto row. // an auto row.
let resolve_breakable = |y, rowspan| { let resolve_breakable = |y, rowspan| {
@ -447,19 +489,20 @@ impl CellGrid {
let mut resolved_cells: Vec<Option<Entry>> = Vec::with_capacity(child_count); let mut resolved_cells: Vec<Option<Entry>> = Vec::with_capacity(child_count);
for child in children { for child in children {
let mut is_header = false; let mut is_header = false;
let mut header_start = usize::MAX; let mut is_footer = false;
let mut header_end = 0; let mut child_start = usize::MAX;
let mut header_span = Span::detached(); let mut child_end = 0;
let mut child_span = Span::detached();
let mut min_auto_index = 0; let mut min_auto_index = 0;
let (header_items, simple_item) = match child { let (header_footer_items, simple_item) = match child {
ResolvableGridChild::Header { repeat, span, items, .. } => { ResolvableGridChild::Header { repeat, span, items, .. } => {
if header.is_some() { if header.is_some() {
bail!(span, "cannot have more than one header"); bail!(span, "cannot have more than one header");
} }
is_header = true; is_header = true;
header_span = span; child_span = span;
repeat_header = repeat; repeat_header = repeat;
// If any cell in the header is automatically positioned, // If any cell in the header is automatically positioned,
@ -472,9 +515,30 @@ impl CellGrid {
(Some(items), None) (Some(items), None)
} }
ResolvableGridChild::Footer { repeat, span, items, .. } => {
if footer.is_some() {
bail!(span, "cannot have more than one footer");
}
is_footer = true;
child_span = span;
repeat_footer = repeat;
// If any cell in the footer is automatically positioned,
// have it skip to the next row. This is to avoid having a
// footer after a partially filled row just add cells to
// that row instead of starting a new one.
min_auto_index = auto_index.next_multiple_of(c);
(Some(items), None)
}
ResolvableGridChild::Item(item) => (None, Some(item)), ResolvableGridChild::Item(item) => (None, Some(item)),
}; };
let items = header_items.into_iter().flatten().chain(simple_item.into_iter());
let items = header_footer_items
.into_iter()
.flatten()
.chain(simple_item.into_iter());
for item in items { for item in items {
let cell = match item { let cell = match item {
ResolvableGridItem::HLine { ResolvableGridItem::HLine {
@ -505,7 +569,7 @@ impl CellGrid {
// minimum it should have for the current grid // minimum it should have for the current grid
// child. Effectively, this means that a hline at // child. Effectively, this means that a hline at
// the start of a header will always appear above // the start of a header will always appear above
// that header's first row. // that header's first row. Similarly for footers.
auto_index auto_index
.max(min_auto_index) .max(min_auto_index)
.checked_sub(1) .checked_sub(1)
@ -560,7 +624,7 @@ impl CellGrid {
// index. For example, this means that a vline at // index. For example, this means that a vline at
// the beginning of a header will be placed to its // the beginning of a header will be placed to its
// left rather than after the previous // left rather than after the previous
// automatically positioned cell. // automatically positioned cell. Same for footers.
auto_index auto_index
.checked_sub(1) .checked_sub(1)
.filter(|last_auto_index| { .filter(|last_auto_index| {
@ -706,25 +770,30 @@ impl CellGrid {
} }
} }
if is_header { if is_header || is_footer {
// Ensure each cell in a header is fully contained within // Ensure each cell in a header or footer is fully
// the header. // contained within it.
header_start = header_start.min(y); child_start = child_start.min(y);
header_end = header_end.max(y + rowspan); child_end = child_end.max(y + rowspan);
}
}
if (is_header || is_footer) && child_start == usize::MAX {
// Empty header/footer: consider the header/footer to be
// one row after the latest auto index.
child_start = auto_index.div_ceil(c);
child_end = child_start + 1;
if resolved_cells.len() <= c * child_start {
// Ensure the automatically chosen row actually exists.
resolved_cells.resize_with(c * (child_start + 1), || None);
} }
} }
if is_header { if is_header {
if header_start == usize::MAX { if child_start != 0 {
// Empty header: consider the header to be one row after
// the latest auto index.
header_start = auto_index.next_multiple_of(c) / c;
header_end = header_start + 1;
}
if header_start != 0 {
bail!( bail!(
header_span, child_span,
"header must start at the first row"; "header must start at the first row";
hint: "remove any rows before the header" hint: "remove any rows before the header"
); );
@ -735,9 +804,29 @@ impl CellGrid {
// is gutter. But only once all cells have been analyzed // is gutter. But only once all cells have been analyzed
// and the header has fully expanded in the fixup loop // and the header has fully expanded in the fixup loop
// below. // below.
end: header_end, end: child_end,
}); });
}
if is_footer {
// Only check if the footer is at the end later, once we know
// the final amount of rows.
footer = Some((
child_end,
child_span,
Footer {
// Later on, we have to correct this number in case there
// is gutter, but only once all cells have been analyzed
// and the header's and footer's exact boundaries are
// known. That is because the gutter row immediately
// before the footer might not be included as part of
// the footer if it is contained within the header.
start: child_start,
},
));
}
if is_header || is_footer {
// Next automatically positioned cell goes under this header. // Next automatically positioned cell goes under this header.
// FIXME: Consider only doing this if the header has any fully // FIXME: Consider only doing this if the header has any fully
// automatically positioned cells. Otherwise, // automatically positioned cells. Otherwise,
@ -751,7 +840,7 @@ impl CellGrid {
// course. // course.
// None of the above are concerns for now, as headers must // None of the above are concerns for now, as headers must
// start at the first row. // start at the first row.
auto_index = auto_index.max(c * header_end); auto_index = auto_index.max(c * child_end);
} }
} }
@ -760,30 +849,58 @@ impl CellGrid {
// vector of 'Entry' from 'Option<Entry>'. // vector of 'Entry' from 'Option<Entry>'.
// 2. If any cells were added to the header's rows after the header's // 2. If any cells were added to the header's rows after the header's
// creation, ensure the header expands enough to accommodate them // creation, ensure the header expands enough to accommodate them
// across all of their spanned rows. // across all of their spanned rows. Same for the footer.
// 3. If any cells before the footer try to span it, error.
let resolved_cells = resolved_cells let resolved_cells = resolved_cells
.into_iter() .into_iter()
.enumerate() .enumerate()
.map(|(i, cell)| { .map(|(i, cell)| {
if let Some(cell) = cell { if let Some(cell) = cell {
if let Some((parent_cell, header)) = if let Some(parent_cell) = cell.as_cell() {
cell.as_cell().zip(header.as_mut()) if let Some(header) = &mut header
{ {
let y = i / c; let y = i / c;
if y < header.end { if y < header.end {
// Ensure the header expands enough such that all // Ensure the header expands enough such that
// cells inside it, even those added later, are // all cells inside it, even those added later,
// fully contained within the header. // are fully contained within the header.
// FIXME: check if start < y < end when start can // FIXME: check if start < y < end when start can
// be != 0. // be != 0.
// FIXME: when start can be != 0, decide what // FIXME: when start can be != 0, decide what
// happens when a cell after the header placed // happens when a cell after the header placed
// above it tries to span the header (either error // above it tries to span the header (either
// or expand upwards). // error or expand upwards).
header.end = header.end.max(y + parent_cell.rowspan.get()); header.end = header.end.max(y + parent_cell.rowspan.get());
} }
} }
if let Some((end, footer_span, footer)) = &mut footer {
let x = i % c;
let y = i / c;
let cell_end = y + parent_cell.rowspan.get();
if y < footer.start && cell_end > footer.start {
// Don't allow a cell before the footer to span
// it. Surely, we could move the footer to
// start at where this cell starts, so this is
// more of a design choice, as it's unlikely
// for the user to intentionally include a cell
// before the footer spanning it but not
// being repeated with it.
bail!(
*footer_span,
"footer would conflict with a cell placed before it at column {x} row {y}";
hint: "try reducing that cell's rowspan or moving the footer"
);
}
if y >= footer.start && y < *end {
// Expand the footer to include all rows
// spanned by this cell, as it is inside the
// footer.
*end = (*end).max(cell_end);
}
}
}
Ok(cell) Ok(cell)
} else { } else {
let x = i % c; let x = i % c;
@ -888,8 +1005,8 @@ impl CellGrid {
vlines[x].push(line); vlines[x].push(line);
} }
// No point in storing the header if it shouldn't be repeated. let header = header
let header = header.filter(|_| repeat_header).map(|mut header| { .map(|mut header| {
// Repeat the gutter below a header (hence why we don't // Repeat the gutter below a header (hence why we don't
// subtract 1 from the gutter case). // subtract 1 from the gutter case).
// Don't do this if there are no rows under the header. // Don't do this if there are no rows under the header.
@ -901,23 +1018,74 @@ impl CellGrid {
// header stops, meaning it will still stop right before it // header stops, meaning it will still stop right before it
// even with gutter thanks to the multiplication below. // even with gutter thanks to the multiplication below.
// - This means that it will span all rows up to // - This means that it will span all rows up to
// '2 * (last y + 1) - 1 = 2 * last y + 1', which equates to // '2 * (last y + 1) - 1 = 2 * last y + 1', which equates
// the index of the gutter row right below the header, which is // to the index of the gutter row right below the header,
// what we want (that gutter spacing should be repeated across // which is what we want (that gutter spacing should be
// pages to maintain uniformity). // repeated across pages to maintain uniformity).
header.end *= 2; header.end *= 2;
// If the header occupies the entire grid, ensure we don't // If the header occupies the entire grid, ensure we don't
// include an extra gutter row when it doesn't exist, since // include an extra gutter row when it doesn't exist, since
// the last row of the header is at the very bottom, therefore // the last row of the header is at the very bottom,
// '2 * last y + 1' is not a valid index. // therefore '2 * last y + 1' is not a valid index.
let row_amount = (2 * row_amount).saturating_sub(1); let row_amount = (2 * row_amount).saturating_sub(1);
header.end = header.end.min(row_amount); header.end = header.end.min(row_amount);
} }
header header
})
.map(|header| {
if repeat_header {
Repeatable::Repeated(header)
} else {
Repeatable::NotRepeated(header)
}
}); });
Ok(Self::new_internal(tracks, gutter, vlines, hlines, header, resolved_cells)) let footer = footer
.map(|(footer_end, footer_span, mut footer)| {
if footer_end != row_amount {
bail!(footer_span, "footer must end at the last row");
}
let header_end =
header.as_ref().map(Repeatable::unwrap).map(|header| header.end);
if has_gutter {
// Convert the footer's start index to post-gutter coordinates.
footer.start *= 2;
// Include the gutter right before the footer, unless there is
// none, or the gutter is already included in the header (no
// rows between the header and the footer).
if header_end.map_or(true, |header_end| header_end != footer.start) {
footer.start = footer.start.saturating_sub(1);
}
}
if header_end.is_some_and(|header_end| header_end > footer.start) {
bail!(footer_span, "header and footer must not have common rows");
}
Ok(footer)
})
.transpose()?
.map(|footer| {
if repeat_footer {
Repeatable::Repeated(footer)
} else {
Repeatable::NotRepeated(footer)
}
});
Ok(Self::new_internal(
tracks,
gutter,
vlines,
hlines,
header,
footer,
resolved_cells,
))
} }
/// Generates the cell grid, given the tracks and resolved entries. /// Generates the cell grid, given the tracks and resolved entries.
@ -926,7 +1094,8 @@ impl CellGrid {
gutter: Axes<&[Sizing]>, gutter: Axes<&[Sizing]>,
vlines: Vec<Vec<Line>>, vlines: Vec<Vec<Line>>,
hlines: Vec<Vec<Line>>, hlines: Vec<Vec<Line>>,
header: Option<Header>, header: Option<Repeatable<Header>>,
footer: Option<Repeatable<Footer>>,
entries: Vec<Entry>, entries: Vec<Entry>,
) -> Self { ) -> Self {
let mut cols = vec![]; let mut cols = vec![];
@ -980,6 +1149,7 @@ impl CellGrid {
vlines, vlines,
hlines, hlines,
header, header,
footer,
has_gutter, has_gutter,
} }
} }
@ -1239,6 +1409,9 @@ pub struct GridLayouter<'a> {
/// header rows themselves are unbreakable, and unbreakable rows do not /// header rows themselves are unbreakable, and unbreakable rows do not
/// need to read this field at all. /// need to read this field at all.
pub(super) header_height: Abs, pub(super) header_height: Abs,
/// The simulated footer height for this region.
/// The simulation occurs before any rows are laid out for a region.
pub(super) footer_height: Abs,
/// The span of the grid element. /// The span of the grid element.
pub(super) span: Span, pub(super) span: Span,
} }
@ -1303,6 +1476,7 @@ impl<'a> GridLayouter<'a> {
finished: vec![], finished: vec![],
is_rtl: TextElem::dir_in(styles) == Dir::RTL, is_rtl: TextElem::dir_in(styles) == Dir::RTL,
header_height: Abs::zero(), header_height: Abs::zero(),
footer_height: Abs::zero(),
span, span,
} }
} }
@ -1311,17 +1485,37 @@ impl<'a> GridLayouter<'a> {
pub fn layout(mut self, engine: &mut Engine) -> SourceResult<Fragment> { pub fn layout(mut self, engine: &mut Engine) -> SourceResult<Fragment> {
self.measure_columns(engine)?; self.measure_columns(engine)?;
if let Some(Repeatable::Repeated(footer)) = &self.grid.footer {
// Ensure rows in the first region will be aware of the possible
// presence of the footer.
self.prepare_footer(footer, engine)?;
if matches!(self.grid.header, None | Some(Repeatable::NotRepeated(_))) {
// No repeatable header, so we won't subtract it later.
self.regions.size.y -= self.footer_height;
}
}
for y in 0..self.grid.rows.len() { for y in 0..self.grid.rows.len() {
if let Some(header) = &self.grid.header { if let Some(Repeatable::Repeated(header)) = &self.grid.header {
if y < header.end { if y < header.end {
if y == 0 { if y == 0 {
self.layout_header(header, engine)?; self.layout_header(header, engine)?;
self.regions.size.y -= self.footer_height;
} }
// Skip header rows during normal layout. // Skip header rows during normal layout.
continue; continue;
} }
} }
if let Some(Repeatable::Repeated(footer)) = &self.grid.footer {
if y >= footer.start {
if y == footer.start {
self.layout_footer(footer, engine)?;
}
continue;
}
}
self.layout_row(y, engine)?; self.layout_row(y, engine)?;
} }
@ -1550,6 +1744,7 @@ impl<'a> GridLayouter<'a> {
.grid .grid
.header .header
.as_ref() .as_ref()
.and_then(Repeatable::as_repeated)
.is_some_and(|header| prev_y + 1 == header.end) .is_some_and(|header| prev_y + 1 == header.end)
}) })
.map(|prev_y| get_hlines_at(prev_y + 1)) .map(|prev_y| get_hlines_at(prev_y + 1))
@ -1573,7 +1768,7 @@ impl<'a> GridLayouter<'a> {
// The header lines, if any, will correspond to the lines under // The header lines, if any, will correspond to the lines under
// the previous row, so they function similarly to 'prev_lines'. // the previous row, so they function similarly to 'prev_lines'.
let expected_header_line_position = expected_prev_line_position; let expected_header_line_position = expected_prev_line_position;
let header_hlines = if let Some((header, prev_y)) = let header_hlines = if let Some((Repeatable::Repeated(header), prev_y)) =
self.grid.header.as_ref().zip(prev_y) self.grid.header.as_ref().zip(prev_y)
{ {
if prev_y + 1 != y if prev_y + 1 != y
@ -2053,7 +2248,13 @@ impl<'a> GridLayouter<'a> {
let frame = self.layout_single_row(engine, first, y)?; let frame = self.layout_single_row(engine, first, y)?;
self.push_row(frame, y, true); self.push_row(frame, y, true);
if self.grid.header.as_ref().is_some_and(|header| y < header.end) { if self
.grid
.header
.as_ref()
.and_then(Repeatable::as_repeated)
.is_some_and(|header| y < header.end)
{
// Add to header height. // Add to header height.
self.header_height += first; self.header_height += first;
} }
@ -2071,10 +2272,16 @@ impl<'a> GridLayouter<'a> {
.zip(&mut resolved[..len - 1]) .zip(&mut resolved[..len - 1])
.skip(self.lrows.iter().any(|row| matches!(row, Row::Fr(..))) as usize) .skip(self.lrows.iter().any(|row| matches!(row, Row::Fr(..))) as usize)
{ {
// Subtract header height from the region height when it's not the // Subtract header and footer heights from the region height when
// first. // it's not the first.
target target.set_max(
.set_max(region.y - if i > 0 { self.header_height } else { Abs::zero() }); region.y
- if i > 0 {
self.header_height + self.footer_height
} else {
Abs::zero()
},
);
} }
// Layout into multiple regions. // Layout into multiple regions.
@ -2277,19 +2484,25 @@ impl<'a> GridLayouter<'a> {
let resolved = v.resolve(self.styles).relative_to(self.regions.base().y); let resolved = v.resolve(self.styles).relative_to(self.regions.base().y);
let frame = self.layout_single_row(engine, resolved, y)?; let frame = self.layout_single_row(engine, resolved, y)?;
if self.grid.header.as_ref().is_some_and(|header| y < header.end) { if self
.grid
.header
.as_ref()
.and_then(Repeatable::as_repeated)
.is_some_and(|header| y < header.end)
{
// Add to header height. // Add to header height.
self.header_height += resolved; self.header_height += resolved;
} }
// Skip to fitting region, but only if we aren't part of an unbreakable // Skip to fitting region, but only if we aren't part of an unbreakable
// row group. We use 'in_last_with_offset' so our 'in_last' call // row group. We use 'in_last_with_offset' so our 'in_last' call
// properly considers that a header would be added on each region // properly considers that a header and a footer would be added on each
// break. // region break.
let height = frame.height(); let height = frame.height();
while self.unbreakable_rows_left == 0 while self.unbreakable_rows_left == 0
&& !self.regions.size.y.fits(height) && !self.regions.size.y.fits(height)
&& !in_last_with_offset(self.regions, self.header_height) && !in_last_with_offset(self.regions, self.header_height + self.footer_height)
{ {
self.finish_region(engine)?; self.finish_region(engine)?;
@ -2421,14 +2634,52 @@ impl<'a> GridLayouter<'a> {
self.lrows.pop().unwrap(); self.lrows.pop().unwrap();
} }
if let Some(header) = &self.grid.header { // If no rows other than the footer have been laid out so far, and
// there are rows beside the footer, then don't lay it out at all.
// This check doesn't apply, and is thus overridden, when there is a
// header.
let mut footer_would_be_orphan = self.lrows.is_empty()
&& !in_last_with_offset(
self.regions,
self.header_height + self.footer_height,
)
&& self
.grid
.footer
.as_ref()
.and_then(Repeatable::as_repeated)
.is_some_and(|footer| footer.start != 0);
if let Some(Repeatable::Repeated(header)) = &self.grid.header {
if self.grid.rows.len() > header.end if self.grid.rows.len() > header.end
&& self
.grid
.footer
.as_ref()
.and_then(Repeatable::as_repeated)
.map_or(true, |footer| footer.start != header.end)
&& self.lrows.last().is_some_and(|row| row.index() < header.end) && self.lrows.last().is_some_and(|row| row.index() < header.end)
&& !in_last_with_offset(self.regions, self.header_height) && !in_last_with_offset(
self.regions,
self.header_height + self.footer_height,
)
{ {
// Header would be alone in this region, but there are more // Header and footer would be alone in this region, but there are more
// rows beyond the header. Push an empty region. // rows beyond the header and the footer. Push an empty region.
self.lrows.clear(); self.lrows.clear();
footer_would_be_orphan = true;
}
}
let mut laid_out_footer_start = None;
if let Some(Repeatable::Repeated(footer)) = &self.grid.footer {
// Don't layout the footer if it would be alone with the header in
// the page, and don't layout it twice.
if !footer_would_be_orphan
&& self.lrows.iter().all(|row| row.index() < footer.start)
{
laid_out_footer_start = Some(footer.start);
self.layout_footer(footer, engine)?;
} }
} }
@ -2523,8 +2774,12 @@ impl<'a> GridLayouter<'a> {
// laid out at the first frame of the row). // laid out at the first frame of the row).
// Any rowspans ending before this row are laid out even // Any rowspans ending before this row are laid out even
// on this row's first frame. // on this row's first frame.
if rowspan.y + rowspan.rowspan < y + 1 if laid_out_footer_start.map_or(true, |footer_start| {
|| rowspan.y + rowspan.rowspan == y + 1 && is_last // If this is a footer row, then only lay out this rowspan
// if the rowspan is contained within the footer.
y < footer_start || rowspan.y >= footer_start
}) && (rowspan.y + rowspan.rowspan < y + 1
|| rowspan.y + rowspan.rowspan == y + 1 && is_last)
{ {
// Rowspan ends at this or an earlier row, so we take // Rowspan ends at this or an earlier row, so we take
// it from the rowspans vector and lay it out. // it from the rowspans vector and lay it out.
@ -2554,11 +2809,18 @@ impl<'a> GridLayouter<'a> {
self.finish_region_internal(output, rrows); self.finish_region_internal(output, rrows);
if let Some(header) = &self.grid.header { if let Some(Repeatable::Repeated(footer)) = &self.grid.footer {
self.prepare_footer(footer, engine)?;
}
if let Some(Repeatable::Repeated(header)) = &self.grid.header {
// Add a header to the new region. // Add a header to the new region.
self.layout_header(header, engine)?; self.layout_header(header, engine)?;
} }
// Ensure rows don't try to overrun the footer.
self.regions.size.y -= self.footer_height;
Ok(()) Ok(())
} }
@ -2579,18 +2841,30 @@ impl<'a> GridLayouter<'a> {
engine: &mut Engine, engine: &mut Engine,
) -> SourceResult<()> { ) -> SourceResult<()> {
let header_rows = self.simulate_header(header, &self.regions, engine)?; let header_rows = self.simulate_header(header, &self.regions, engine)?;
let mut skipped_region = false;
while self.unbreakable_rows_left == 0 while self.unbreakable_rows_left == 0
&& !self.regions.size.y.fits(header_rows.height) && !self.regions.size.y.fits(header_rows.height + self.footer_height)
&& !self.regions.in_last() && !self.regions.in_last()
{ {
// Advance regions without any output until we can place the // Advance regions without any output until we can place the
// header. // header and the footer.
self.finish_region_internal(Frame::soft(Axes::splat(Abs::zero())), vec![]); self.finish_region_internal(Frame::soft(Axes::splat(Abs::zero())), vec![]);
skipped_region = true;
} }
// Reset the header height for this region. // Reset the header height for this region.
// It will be re-calculated when laying out each header row.
self.header_height = Abs::zero(); self.header_height = Abs::zero();
if let Some(Repeatable::Repeated(footer)) = &self.grid.footer {
if skipped_region {
// Simulate the footer again; the region's 'full' might have
// changed.
self.footer_height =
self.simulate_footer(footer, &self.regions, engine)?.height;
}
}
// Header is unbreakable. // Header is unbreakable.
// Thus, no risk of 'finish_region' being recursively called from // Thus, no risk of 'finish_region' being recursively called from
// within 'layout_row'. // within 'layout_row'.
@ -2618,6 +2892,78 @@ impl<'a> GridLayouter<'a> {
Ok(header_row_group) Ok(header_row_group)
} }
/// Updates `self.footer_height` by simulating the footer, and skips to fitting region.
pub(super) fn prepare_footer(
&mut self,
footer: &Footer,
engine: &mut Engine,
) -> SourceResult<()> {
let footer_height = self.simulate_footer(footer, &self.regions, engine)?.height;
let mut skipped_region = false;
while self.unbreakable_rows_left == 0
&& !self.regions.size.y.fits(footer_height)
&& !self.regions.in_last()
{
// Advance regions without any output until we can place the
// footer.
self.finish_region_internal(Frame::soft(Axes::splat(Abs::zero())), vec![]);
skipped_region = true;
}
self.footer_height = if skipped_region {
// Simulate the footer again; the region's 'full' might have
// changed.
self.simulate_footer(footer, &self.regions, engine)?.height
} else {
footer_height
};
Ok(())
}
/// Lays out all rows in the footer.
/// They are unbreakable.
pub(super) fn layout_footer(
&mut self,
footer: &Footer,
engine: &mut Engine,
) -> SourceResult<()> {
// Ensure footer rows have their own height available.
// Won't change much as we're creating an unbreakable row group
// anyway, so this is mostly for correctness.
self.regions.size.y += self.footer_height;
let footer_len = self.grid.rows.len() - footer.start;
self.unbreakable_rows_left += footer_len;
for y in footer.start..self.grid.rows.len() {
self.layout_row(y, engine)?;
}
Ok(())
}
// Simulate the footer's group of rows.
pub(super) fn simulate_footer(
&self,
footer: &Footer,
regions: &Regions<'_>,
engine: &mut Engine,
) -> SourceResult<UnbreakableRowGroup> {
// Note that we assume the invariant that any rowspan in a footer is
// fully contained within that footer. Therefore, there won't be any
// unbreakable rowspans exceeding the footer's rows, and we can safely
// assume that the amount of unbreakable rows following the first row
// in the footer will be precisely the rows in the footer.
let footer_row_group = self.simulate_unbreakable_row_group(
footer.start,
Some(self.grid.rows.len() - footer.start),
regions,
engine,
)?;
Ok(footer_row_group)
}
} }
/// Turn an iterator of extents into an iterator of offsets before, in between, /// Turn an iterator of extents into an iterator of offsets before, in between,

View File

@ -1,7 +1,7 @@
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
use std::sync::Arc; use std::sync::Arc;
use super::layout::{CellGrid, RowPiece}; use super::layout::{CellGrid, Repeatable, RowPiece};
use crate::foundations::{AlternativeFold, Fold}; use crate::foundations::{AlternativeFold, Fold};
use crate::layout::Abs; use crate::layout::Abs;
use crate::visualize::Stroke; use crate::visualize::Stroke;
@ -538,9 +538,10 @@ pub(super) fn hline_stroke_at_column(
// Top border stroke and header stroke are generally prioritized, unless // Top border stroke and header stroke are generally prioritized, unless
// they don't have explicit hline overrides and one or more user-provided // they don't have explicit hline overrides and one or more user-provided
// hlines would appear at the same position, which then are prioritized. // hlines would appear at the same position, which then are prioritized.
let top_stroke_comes_from_header = let top_stroke_comes_from_header = grid
grid.header .header
.as_ref() .as_ref()
.and_then(Repeatable::as_repeated)
.zip(local_top_y) .zip(local_top_y)
.is_some_and(|(header, local_top_y)| { .is_some_and(|(header, local_top_y)| {
// Ensure the row above us is a repeated header. // Ensure the row above us is a repeated header.
@ -549,8 +550,21 @@ pub(super) fn hline_stroke_at_column(
local_top_y + 1 == header.end && y != header.end local_top_y + 1 == header.end && y != header.end
}); });
// Prioritize the footer's top stroke as well where applicable.
let bottom_stroke_comes_from_footer = grid
.footer
.as_ref()
.and_then(Repeatable::as_repeated)
.is_some_and(|footer| {
// Ensure the row below us is a repeated footer.
// FIXME: Make this check more robust when footers at arbitrary
// positions are added.
local_top_y.unwrap_or(0) + 1 != footer.start && y == footer.start
});
let (prioritized_cell_stroke, deprioritized_cell_stroke) = let (prioritized_cell_stroke, deprioritized_cell_stroke) =
if !use_bottom_border_stroke if !use_bottom_border_stroke
&& !bottom_stroke_comes_from_footer
&& (use_top_border_stroke && (use_top_border_stroke
|| top_stroke_comes_from_header || top_stroke_comes_from_header
|| top_cell_prioritized && !bottom_cell_prioritized) || top_cell_prioritized && !bottom_cell_prioritized)
@ -562,7 +576,7 @@ pub(super) fn hline_stroke_at_column(
// When both cells' strokes have the same priority, we default to // When both cells' strokes have the same priority, we default to
// prioritizing the bottom cell's top stroke. // prioritizing the bottom cell's top stroke.
// Additionally, the bottom border cell's stroke always has // Additionally, the bottom border cell's stroke always has
// priority. // priority. Same for stroke above footers.
(bottom_cell_stroke, top_cell_stroke) (bottom_cell_stroke, top_cell_stroke)
}; };
@ -658,6 +672,7 @@ mod test {
vec![], vec![],
vec![], vec![],
None, None,
None,
entries, entries,
) )
} }
@ -1195,6 +1210,7 @@ mod test {
vec![], vec![],
vec![], vec![],
None, None,
None,
entries, entries,
) )
} }

View File

@ -23,7 +23,7 @@ use crate::layout::{
Abs, AlignElem, Alignment, Axes, Dir, Fragment, LayoutMultiple, Length, Abs, AlignElem, Alignment, Axes, Dir, Fragment, LayoutMultiple, Length,
OuterHAlignment, OuterVAlignment, Regions, Rel, Sides, Sizing, OuterHAlignment, OuterVAlignment, Regions, Rel, Sides, Sizing,
}; };
use crate::model::{TableCell, TableHLine, TableHeader, TableVLine}; use crate::model::{TableCell, TableFooter, TableHLine, TableHeader, TableVLine};
use crate::syntax::Span; use crate::syntax::Span;
use crate::text::TextElem; use crate::text::TextElem;
use crate::util::NonZeroExt; use crate::util::NonZeroExt;
@ -299,6 +299,9 @@ impl GridElem {
#[elem] #[elem]
type GridHeader; type GridHeader;
#[elem]
type GridFooter;
} }
impl LayoutMultiple for Packed<GridElem> { impl LayoutMultiple for Packed<GridElem> {
@ -322,11 +325,17 @@ impl LayoutMultiple for Packed<GridElem> {
let gutter = Axes::new(column_gutter.0.as_slice(), row_gutter.0.as_slice()); let gutter = Axes::new(column_gutter.0.as_slice(), row_gutter.0.as_slice());
// Use trace to link back to the grid when a specific cell errors // Use trace to link back to the grid when a specific cell errors
let tracepoint = || Tracepoint::Call(Some(eco_format!("grid"))); let tracepoint = || Tracepoint::Call(Some(eco_format!("grid")));
let resolve_item = |item: &GridItem| item.to_resolvable(styles);
let children = self.children().iter().map(|child| match child { let children = self.children().iter().map(|child| match child {
GridChild::Header(header) => ResolvableGridChild::Header { GridChild::Header(header) => ResolvableGridChild::Header {
repeat: header.repeat(styles), repeat: header.repeat(styles),
span: header.span(), span: header.span(),
items: header.children().iter().map(|child| child.to_resolvable(styles)), items: header.children().iter().map(resolve_item),
},
GridChild::Footer(footer) => ResolvableGridChild::Footer {
repeat: footer.repeat(styles),
span: footer.span(),
items: footer.children().iter().map(resolve_item),
}, },
GridChild::Item(item) => { GridChild::Item(item) => {
ResolvableGridChild::Item(item.to_resolvable(styles)) ResolvableGridChild::Item(item.to_resolvable(styles))
@ -369,6 +378,7 @@ cast! {
#[derive(Debug, PartialEq, Clone, Hash)] #[derive(Debug, PartialEq, Clone, Hash)]
pub enum GridChild { pub enum GridChild {
Header(Packed<GridHeader>), Header(Packed<GridHeader>),
Footer(Packed<GridFooter>),
Item(GridItem), Item(GridItem),
} }
@ -376,6 +386,7 @@ cast! {
GridChild, GridChild,
self => match self { self => match self {
Self::Header(header) => header.into_value(), Self::Header(header) => header.into_value(),
Self::Footer(footer) => footer.into_value(),
Self::Item(item) => item.into_value(), Self::Item(item) => item.into_value(),
}, },
v: Content => { v: Content => {
@ -389,10 +400,14 @@ impl TryFrom<Content> for GridChild {
if value.is::<TableHeader>() { if value.is::<TableHeader>() {
bail!("cannot use `table.header` as a grid header; use `grid.header` instead") bail!("cannot use `table.header` as a grid header; use `grid.header` instead")
} }
if value.is::<TableFooter>() {
bail!("cannot use `table.footer` as a grid footer; use `grid.footer` instead")
}
value value
.into_packed::<GridHeader>() .into_packed::<GridHeader>()
.map(Self::Header) .map(Self::Header)
.or_else(|value| value.into_packed::<GridFooter>().map(Self::Footer))
.or_else(|value| GridItem::try_from(value).map(Self::Item)) .or_else(|value| GridItem::try_from(value).map(Self::Item))
} }
} }
@ -459,10 +474,16 @@ impl TryFrom<Content> for GridItem {
type Error = EcoString; type Error = EcoString;
fn try_from(value: Content) -> StrResult<Self> { fn try_from(value: Content) -> StrResult<Self> {
if value.is::<GridHeader>() { if value.is::<GridHeader>() {
bail!("cannot place a grid header within another header"); bail!("cannot place a grid header within another header or footer");
} }
if value.is::<TableHeader>() { if value.is::<TableHeader>() {
bail!("cannot place a table header within another header"); bail!("cannot place a table header within another header or footer");
}
if value.is::<GridFooter>() {
bail!("cannot place a grid footer within another footer or header");
}
if value.is::<TableFooter>() {
bail!("cannot place a table footer within another footer or header");
} }
if value.is::<TableCell>() { if value.is::<TableCell>() {
bail!("cannot use `table.cell` as a grid cell; use `grid.cell` instead"); bail!("cannot use `table.cell` as a grid cell; use `grid.cell` instead");
@ -498,6 +519,18 @@ pub struct GridHeader {
pub children: Vec<GridItem>, pub children: Vec<GridItem>,
} }
/// A repeatable grid footer.
#[elem(name = "footer", title = "Grid Footer")]
pub struct GridFooter {
/// Whether this footer should be repeated across pages.
#[default(true)]
pub repeat: bool,
/// The cells and lines within the footer.
#[variadic]
pub children: Vec<GridItem>,
}
/// A horizontal line in the grid. /// A horizontal line in the grid.
/// ///
/// Overrides any per-cell stroke, including stroke specified through the /// Overrides any per-cell stroke, including stroke specified through the

View File

@ -6,7 +6,7 @@ use crate::layout::{
}; };
use crate::util::MaybeReverseIter; use crate::util::MaybeReverseIter;
use super::layout::{in_last_with_offset, points, Row, RowPiece}; use super::layout::{in_last_with_offset, points, Repeatable, Row, RowPiece};
/// All information needed to layout a single rowspan. /// All information needed to layout a single rowspan.
pub(super) struct Rowspan { pub(super) struct Rowspan {
@ -132,7 +132,7 @@ impl<'a> GridLayouter<'a> {
// The rowspan continuation starts after the header (thus, // The rowspan continuation starts after the header (thus,
// at a position after the sum of the laid out header // at a position after the sum of the laid out header
// rows). // rows).
if let Some(header) = &self.grid.header { if let Some(Repeatable::Repeated(header)) = &self.grid.header {
let header_rows = self let header_rows = self
.rrows .rrows
.get(i) .get(i)
@ -194,16 +194,36 @@ impl<'a> GridLayouter<'a> {
engine: &mut Engine, engine: &mut Engine,
) -> SourceResult<()> { ) -> SourceResult<()> {
if self.unbreakable_rows_left == 0 { if self.unbreakable_rows_left == 0 {
// By default, the amount of unbreakable rows starting at the
// current row is dynamic and depends on the amount of upcoming
// unbreakable cells (with or without a rowspan setting).
let mut amount_unbreakable_rows = None;
if let Some(Repeatable::NotRepeated(header)) = &self.grid.header {
if current_row < header.end {
// Non-repeated header, so keep it unbreakable.
amount_unbreakable_rows = Some(header.end);
}
}
if let Some(Repeatable::NotRepeated(footer)) = &self.grid.footer {
if current_row >= footer.start {
// Non-repeated footer, so keep it unbreakable.
amount_unbreakable_rows = Some(self.grid.rows.len() - footer.start);
}
}
let row_group = self.simulate_unbreakable_row_group( let row_group = self.simulate_unbreakable_row_group(
current_row, current_row,
None, amount_unbreakable_rows,
&self.regions, &self.regions,
engine, engine,
)?; )?;
// Skip to fitting region. // Skip to fitting region.
while !self.regions.size.y.fits(row_group.height) while !self.regions.size.y.fits(row_group.height)
&& !in_last_with_offset(self.regions, self.header_height) && !in_last_with_offset(
self.regions,
self.header_height + self.footer_height,
)
{ {
self.finish_region(engine)?; self.finish_region(engine)?;
} }
@ -305,27 +325,31 @@ impl<'a> GridLayouter<'a> {
let rowspan = self.grid.effective_rowspan_of_cell(cell); let rowspan = self.grid.effective_rowspan_of_cell(cell);
// This variable is used to construct a custom backlog if the cell // This variable is used to construct a custom backlog if the cell
// is a rowspan, or if headers are used. When measuring, we join // is a rowspan, or if headers or footers are used. When measuring, we
// the heights from previous regions to the current backlog to form // join the heights from previous regions to the current backlog to
// a rowspan's expected backlog. We also subtract the header's // form a rowspan's expected backlog. We also subtract the header's
// height from all regions. // and footer's heights from all regions.
let mut custom_backlog: Vec<Abs> = vec![]; let mut custom_backlog: Vec<Abs> = vec![];
// This function is used to subtract the expected header height from // This function is used to subtract the expected header and footer
// each upcoming region size in the current backlog and last region. // height from each upcoming region size in the current backlog and
let mut subtract_header_height_from_regions = || { // last region.
let mut subtract_header_footer_height_from_regions = || {
// Only breakable auto rows need to update their backlogs based // Only breakable auto rows need to update their backlogs based
// on the presence of a header, given that unbreakable auto // on the presence of a header or footer, given that unbreakable
// rows don't depend on the backlog, as they only span one // auto rows don't depend on the backlog, as they only span one
// region. // region.
if breakable && self.grid.header.is_some() { if breakable
// Subtract header height from all upcoming regions when && (matches!(self.grid.header, Some(Repeatable::Repeated(_)))
// measuring the cell, including the last repeated region. || matches!(self.grid.footer, Some(Repeatable::Repeated(_))))
{
// Subtract header and footer height from all upcoming regions
// when measuring the cell, including the last repeated region.
// //
// This will update the 'custom_backlog' vector with the // This will update the 'custom_backlog' vector with the
// updated heights of the upcoming regions. // updated heights of the upcoming regions.
let mapped_regions = self.regions.map(&mut custom_backlog, |size| { let mapped_regions = self.regions.map(&mut custom_backlog, |size| {
Size::new(size.x, size.y - self.header_height) Size::new(size.x, size.y - self.header_height - self.footer_height)
}); });
// Callees must use the custom backlog instead of the current // Callees must use the custom backlog instead of the current
@ -365,13 +389,13 @@ impl<'a> GridLayouter<'a> {
// However, if the auto row is unbreakable, measure with infinite // However, if the auto row is unbreakable, measure with infinite
// height instead to see how much content expands. // height instead to see how much content expands.
// 2. Use the region's backlog and last region when measuring, // 2. Use the region's backlog and last region when measuring,
// however subtract the expected header height from each upcoming // however subtract the expected header and footer heights from
// size, if there is a header. // each upcoming size, if there is a header or footer.
// 3. Use the same full region height. // 3. Use the same full region height.
// 4. No height occupied by this cell in this region so far. // 4. No height occupied by this cell in this region so far.
// 5. Yes, this cell started in this region. // 5. Yes, this cell started in this region.
height = if breakable { self.regions.size.y } else { Abs::inf() }; height = if breakable { self.regions.size.y } else { Abs::inf() };
(backlog, last) = subtract_header_height_from_regions(); (backlog, last) = subtract_header_footer_height_from_regions();
full = if breakable { self.regions.full } else { Abs::inf() }; full = if breakable { self.regions.full } else { Abs::inf() };
height_in_this_region = Abs::zero(); height_in_this_region = Abs::zero();
frames_in_previous_regions = 0; frames_in_previous_regions = 0;
@ -426,7 +450,7 @@ impl<'a> GridLayouter<'a> {
.iter() .iter()
.copied() .copied()
.chain(std::iter::once(if breakable { .chain(std::iter::once(if breakable {
self.initial.y - self.header_height self.initial.y - self.header_height - self.footer_height
} else { } else {
// When measuring unbreakable auto rows, infinite // When measuring unbreakable auto rows, infinite
// height is available for content to expand. // height is available for content to expand.
@ -442,7 +466,7 @@ impl<'a> GridLayouter<'a> {
.regions .regions
.backlog .backlog
.iter() .iter()
.map(|&size| size - self.header_height); .map(|&size| size - self.header_height - self.footer_height);
heights_up_to_current_region.chain(backlog).collect::<Vec<_>>() heights_up_to_current_region.chain(backlog).collect::<Vec<_>>()
} else { } else {
@ -456,7 +480,10 @@ impl<'a> GridLayouter<'a> {
height = *rowspan_height; height = *rowspan_height;
backlog = None; backlog = None;
full = rowspan_full; full = rowspan_full;
last = self.regions.last.map(|size| size - self.header_height); last = self
.regions
.last
.map(|size| size - self.header_height - self.footer_height);
} else { } else {
// The rowspan started in the current region, as its vector // The rowspan started in the current region, as its vector
// of heights in regions is currently empty. // of heights in regions is currently empty.
@ -472,7 +499,7 @@ impl<'a> GridLayouter<'a> {
} else { } else {
Abs::inf() Abs::inf()
}; };
(backlog, last) = subtract_header_height_from_regions(); (backlog, last) = subtract_header_footer_height_from_regions();
full = if breakable { self.regions.full } else { Abs::inf() }; full = if breakable { self.regions.full } else { Abs::inf() };
frames_in_previous_regions = 0; frames_in_previous_regions = 0;
} }
@ -655,10 +682,10 @@ impl<'a> GridLayouter<'a> {
// resolved vector, above. // resolved vector, above.
simulated_regions.next(); simulated_regions.next();
// Subtract the initial header height, since that's the height we // Subtract the initial header and footer height, since that's the
// used when subtracting from the region backlog's heights while // height we used when subtracting from the region backlog's
// measuring cells. // heights while measuring cells.
simulated_regions.size.y -= self.header_height; simulated_regions.size.y -= self.header_height + self.footer_height;
} }
if let Some(original_last_resolved_size) = last_resolved_size { if let Some(original_last_resolved_size) = last_resolved_size {
@ -788,8 +815,11 @@ impl<'a> GridLayouter<'a> {
// which, when used and combined with upcoming spanned rows, covers all // which, when used and combined with upcoming spanned rows, covers all
// of the requested rowspan height, we give up. // of the requested rowspan height, we give up.
for _attempt in 0..5 { for _attempt in 0..5 {
let rowspan_simulator = let rowspan_simulator = RowspanSimulator::new(
RowspanSimulator::new(simulated_regions, self.header_height); simulated_regions,
self.header_height,
self.footer_height,
);
let total_spanned_height = rowspan_simulator.simulate_rowspan_layout( let total_spanned_height = rowspan_simulator.simulate_rowspan_layout(
y, y,
@ -871,7 +901,7 @@ impl<'a> GridLayouter<'a> {
{ {
extra_amount_to_grow -= simulated_regions.size.y.max(Abs::zero()); extra_amount_to_grow -= simulated_regions.size.y.max(Abs::zero());
simulated_regions.next(); simulated_regions.next();
simulated_regions.size.y -= self.header_height; simulated_regions.size.y -= self.header_height + self.footer_height;
} }
simulated_regions.size.y -= extra_amount_to_grow; simulated_regions.size.y -= extra_amount_to_grow;
} }
@ -887,6 +917,8 @@ struct RowspanSimulator<'a> {
regions: Regions<'a>, regions: Regions<'a>,
/// The height of the header in the currently simulated region. /// The height of the header in the currently simulated region.
header_height: Abs, header_height: Abs,
/// The height of the footer in the currently simulated region.
footer_height: Abs,
/// The total spanned height so far in the simulation. /// The total spanned height so far in the simulation.
total_spanned_height: Abs, total_spanned_height: Abs,
/// Height of the latest spanned gutter row in the simulation. /// Height of the latest spanned gutter row in the simulation.
@ -896,11 +928,12 @@ struct RowspanSimulator<'a> {
impl<'a> RowspanSimulator<'a> { impl<'a> RowspanSimulator<'a> {
/// Creates new rowspan simulation state with the given regions and initial /// Creates new rowspan simulation state with the given regions and initial
/// header height. Other fields should always start as zero. /// header and footer heights. Other fields should always start as zero.
fn new(regions: Regions<'a>, header_height: Abs) -> Self { fn new(regions: Regions<'a>, header_height: Abs, footer_height: Abs) -> Self {
Self { Self {
regions, regions,
header_height, header_height,
footer_height,
total_spanned_height: Abs::zero(), total_spanned_height: Abs::zero(),
latest_spanned_gutter_height: Abs::zero(), latest_spanned_gutter_height: Abs::zero(),
} }
@ -948,7 +981,10 @@ impl<'a> RowspanSimulator<'a> {
engine, engine,
)?; )?;
while !self.regions.size.y.fits(row_group.height) while !self.regions.size.y.fits(row_group.height)
&& !in_last_with_offset(self.regions, self.header_height) && !in_last_with_offset(
self.regions,
self.header_height + self.footer_height,
)
{ {
self.finish_region(layouter, engine)?; self.finish_region(layouter, engine)?;
} }
@ -970,7 +1006,10 @@ impl<'a> RowspanSimulator<'a> {
let mut skipped_region = false; let mut skipped_region = false;
while unbreakable_rows_left == 0 while unbreakable_rows_left == 0
&& !self.regions.size.y.fits(height) && !self.regions.size.y.fits(height)
&& !in_last_with_offset(self.regions, self.header_height) && !in_last_with_offset(
self.regions,
self.header_height + self.footer_height,
)
{ {
self.finish_region(layouter, engine)?; self.finish_region(layouter, engine)?;
@ -1002,50 +1041,69 @@ impl<'a> RowspanSimulator<'a> {
Ok(self.total_spanned_height) Ok(self.total_spanned_height)
} }
fn simulate_header_layout( fn simulate_header_footer_layout(
&mut self, &mut self,
layouter: &GridLayouter<'_>, layouter: &GridLayouter<'_>,
engine: &mut Engine, engine: &mut Engine,
) -> SourceResult<()> { ) -> SourceResult<()> {
if let Some(header) = &layouter.grid.header { // We can't just use the initial header/footer height on each region,
// We can't just use the initial header height on each // because header/footer height might vary depending on region size if
// region, because header height might vary depending // it contains rows with relative lengths. Therefore, we re-simulate
// on region size if it contains rows with relative // headers and footers on each new region.
// lengths. Therefore, we re-simulate headers on each // It's true that, when measuring cells, we reduce each height in the
// new region. // backlog to consider the initial header and footer heights; however,
// It's true that, when measuring cells, we reduce each // our simulation checks what happens AFTER the auto row, so we can
// height in the backlog to consider the initial header // just use the original backlog from `self.regions`.
// height; however, our simulation checks what happens let header_height =
// AFTER the auto row, so we can just use the original if let Some(Repeatable::Repeated(header)) = &layouter.grid.header {
// backlog from `self.regions`. layouter.simulate_header(header, &self.regions, engine)?.height
let header_row_group = } else {
layouter.simulate_header(header, &self.regions, engine)?; Abs::zero()
};
let footer_height =
if let Some(Repeatable::Repeated(footer)) = &layouter.grid.footer {
layouter.simulate_footer(footer, &self.regions, engine)?.height
} else {
Abs::zero()
};
let mut skipped_region = false; let mut skipped_region = false;
// Skip until we reach a fitting region for this header. // Skip until we reach a fitting region for both header and footer.
while !self.regions.size.y.fits(header_row_group.height) while !self.regions.size.y.fits(header_height + footer_height)
&& !self.regions.in_last() && !self.regions.in_last()
{ {
self.regions.next(); self.regions.next();
skipped_region = true; skipped_region = true;
} }
if let Some(Repeatable::Repeated(header)) = &layouter.grid.header {
self.header_height = if skipped_region { self.header_height = if skipped_region {
// Simulate headers again, at the new region, as // Simulate headers again, at the new region, as
// the full region height may change. // the full region height may change.
layouter.simulate_header(header, &self.regions, engine)?.height layouter.simulate_header(header, &self.regions, engine)?.height
} else { } else {
header_row_group.height header_height
}; };
// Consume the header's height from the new region,
// but don't consider it spanned. The rowspan
// does not go over the header (as an invariant,
// any rowspans spanning a header row are fully
// contained within that header's rows).
self.regions.size.y -= self.header_height;
} }
if let Some(Repeatable::Repeated(footer)) = &layouter.grid.footer {
self.footer_height = if skipped_region {
// Simulate footers again, at the new region, as
// the full region height may change.
layouter.simulate_footer(footer, &self.regions, engine)?.height
} else {
footer_height
};
}
// Consume the header's and footer's heights from the new region,
// but don't consider them spanned. The rowspan does not go over the
// header or footer (as an invariant, any rowspans spanning any header
// or footer rows are fully contained within that header's or footer's rows).
self.regions.size.y -= self.header_height + self.footer_height;
Ok(()) Ok(())
} }
@ -1060,7 +1118,7 @@ impl<'a> RowspanSimulator<'a> {
self.latest_spanned_gutter_height = Abs::zero(); self.latest_spanned_gutter_height = Abs::zero();
self.regions.next(); self.regions.next();
self.simulate_header_layout(layouter, engine) self.simulate_header_footer_layout(layouter, engine)
} }
} }

View File

@ -10,8 +10,8 @@ use crate::foundations::{
}; };
use crate::layout::{ use crate::layout::{
show_grid_cell, Abs, Alignment, Axes, Cell, CellGrid, Celled, Dir, Fragment, show_grid_cell, Abs, Alignment, Axes, Cell, CellGrid, Celled, Dir, Fragment,
GridCell, GridHLine, GridHeader, GridLayouter, GridVLine, LayoutMultiple, Length, GridCell, GridFooter, GridHLine, GridHeader, GridLayouter, GridVLine, LayoutMultiple,
LinePosition, OuterHAlignment, OuterVAlignment, Regions, Rel, ResolvableCell, Length, LinePosition, OuterHAlignment, OuterVAlignment, Regions, Rel, ResolvableCell,
ResolvableGridChild, ResolvableGridItem, Sides, TrackSizings, ResolvableGridChild, ResolvableGridItem, Sides, TrackSizings,
}; };
use crate::model::Figurable; use crate::model::Figurable;
@ -224,6 +224,9 @@ impl TableElem {
#[elem] #[elem]
type TableHeader; type TableHeader;
#[elem]
type TableFooter;
} }
impl LayoutMultiple for Packed<TableElem> { impl LayoutMultiple for Packed<TableElem> {
@ -247,11 +250,17 @@ impl LayoutMultiple for Packed<TableElem> {
let gutter = Axes::new(column_gutter.0.as_slice(), row_gutter.0.as_slice()); let gutter = Axes::new(column_gutter.0.as_slice(), row_gutter.0.as_slice());
// Use trace to link back to the table when a specific cell errors // Use trace to link back to the table when a specific cell errors
let tracepoint = || Tracepoint::Call(Some(eco_format!("table"))); let tracepoint = || Tracepoint::Call(Some(eco_format!("table")));
let resolve_item = |item: &TableItem| item.to_resolvable(styles);
let children = self.children().iter().map(|child| match child { let children = self.children().iter().map(|child| match child {
TableChild::Header(header) => ResolvableGridChild::Header { TableChild::Header(header) => ResolvableGridChild::Header {
repeat: header.repeat(styles), repeat: header.repeat(styles),
span: header.span(), span: header.span(),
items: header.children().iter().map(|child| child.to_resolvable(styles)), items: header.children().iter().map(resolve_item),
},
TableChild::Footer(footer) => ResolvableGridChild::Footer {
repeat: footer.repeat(styles),
span: footer.span(),
items: footer.children().iter().map(resolve_item),
}, },
TableChild::Item(item) => { TableChild::Item(item) => {
ResolvableGridChild::Item(item.to_resolvable(styles)) ResolvableGridChild::Item(item.to_resolvable(styles))
@ -319,6 +328,7 @@ impl Figurable for Packed<TableElem> {}
#[derive(Debug, PartialEq, Clone, Hash)] #[derive(Debug, PartialEq, Clone, Hash)]
pub enum TableChild { pub enum TableChild {
Header(Packed<TableHeader>), Header(Packed<TableHeader>),
Footer(Packed<TableFooter>),
Item(TableItem), Item(TableItem),
} }
@ -326,6 +336,7 @@ cast! {
TableChild, TableChild,
self => match self { self => match self {
Self::Header(header) => header.into_value(), Self::Header(header) => header.into_value(),
Self::Footer(footer) => footer.into_value(),
Self::Item(item) => item.into_value(), Self::Item(item) => item.into_value(),
}, },
v: Content => { v: Content => {
@ -342,10 +353,16 @@ impl TryFrom<Content> for TableChild {
"cannot use `grid.header` as a table header; use `table.header` instead" "cannot use `grid.header` as a table header; use `table.header` instead"
) )
} }
if value.is::<GridFooter>() {
bail!(
"cannot use `grid.footer` as a table footer; use `table.footer` instead"
)
}
value value
.into_packed::<TableHeader>() .into_packed::<TableHeader>()
.map(Self::Header) .map(Self::Header)
.or_else(|value| value.into_packed::<TableFooter>().map(Self::Footer))
.or_else(|value| TableItem::try_from(value).map(Self::Item)) .or_else(|value| TableItem::try_from(value).map(Self::Item))
} }
} }
@ -413,10 +430,16 @@ impl TryFrom<Content> for TableItem {
fn try_from(value: Content) -> StrResult<Self> { fn try_from(value: Content) -> StrResult<Self> {
if value.is::<GridHeader>() { if value.is::<GridHeader>() {
bail!("cannot place a grid header within another header"); bail!("cannot place a grid header within another header or footer");
} }
if value.is::<TableHeader>() { if value.is::<TableHeader>() {
bail!("cannot place a table header within another header"); bail!("cannot place a table header within another header or footer");
}
if value.is::<GridFooter>() {
bail!("cannot place a grid footer within another footer or header");
}
if value.is::<TableFooter>() {
bail!("cannot place a table footer within another footer or header");
} }
if value.is::<GridCell>() { if value.is::<GridCell>() {
bail!("cannot use `grid.cell` as a table cell; use `table.cell` instead"); bail!("cannot use `grid.cell` as a table cell; use `table.cell` instead");
@ -452,6 +475,18 @@ pub struct TableHeader {
pub children: Vec<TableItem>, pub children: Vec<TableItem>,
} }
/// A repeatable table footer.
#[elem(name = "footer", title = "Table Footer")]
pub struct TableFooter {
/// Whether this footer should be repeated across pages.
#[default(true)]
pub repeat: bool,
/// The cells and lines within the footer.
#[variadic]
pub children: Vec<TableItem>,
}
/// A horizontal line in the table. See the docs for /// A horizontal line in the table. See the docs for
/// [`grid.hline`]($grid.hline) for more information regarding how to use this /// [`grid.hline`]($grid.hline) for more information regarding how to use this
/// element's fields. /// element's fields.

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@ -0,0 +1,192 @@
#set page(width: auto, height: 15em)
#set text(6pt)
#set table(inset: 2pt, stroke: 0.5pt)
#table(
columns: 5,
align: center + horizon,
table.header(
table.cell(colspan: 5)[*Cool Zone*],
table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*],
table.hline(start: 2, end: 3, stroke: yellow)
),
..range(0, 5).map(i => ([John \##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten(),
table.footer(
table.hline(start: 2, end: 3, stroke: yellow),
table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*],
table.cell(colspan: 5)[*Cool Zone*]
)
)
---
// Gutter & no repetition
#set page(width: auto, height: 16em)
#set text(6pt)
#set table(inset: 2pt, stroke: 0.5pt)
#table(
columns: 5,
gutter: 2pt,
align: center + horizon,
table.header(
table.cell(colspan: 5)[*Cool Zone*],
table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*],
table.hline(start: 2, end: 3, stroke: yellow)
),
..range(0, 5).map(i => ([John \##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten(),
table.footer(
repeat: false,
table.hline(start: 2, end: 3, stroke: yellow),
table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*],
table.cell(colspan: 5)[*Cool Zone*]
)
)
---
#table(
table.header(table.cell(stroke: red)[Hello]),
table.footer(table.cell(stroke: aqua)[Bye]),
)
---
#table(
gutter: 3pt,
table.header(table.cell(stroke: red)[Hello]),
table.footer(table.cell(stroke: aqua)[Bye]),
)
---
// Footer's top stroke should win when repeated, but lose at the last page.
#set page(height: 10em)
#table(
stroke: green,
table.header(table.cell(stroke: red)[Hello]),
table.cell(stroke: yellow)[Hi],
table.cell(stroke: yellow)[Bye],
table.cell(stroke: yellow)[Ok],
table.footer[Bye],
)
---
// Relative lengths
#set page(height: 10em)
#table(
rows: (30%, 30%, auto),
[C],
[C],
table.footer[*A*][*B*],
)
---
#grid(
grid.footer(grid.cell(y: 2)[b]),
grid.cell(y: 0)[a],
grid.cell(y: 1)[c],
)
---
// Ensure footer properly expands
#grid(
columns: 2,
[a], [],
[b], [],
grid.cell(x: 1, y: 3, rowspan: 4)[b],
grid.cell(y: 2, rowspan: 2)[a],
grid.footer(),
grid.cell(y: 4)[d],
grid.cell(y: 5)[e],
grid.cell(y: 6)[f],
)
---
// Error: 2:3-2:19 footer must end at the last row
#grid(
grid.footer([a]),
[b],
)
---
// Error: 3:3-3:19 footer must end at the last row
#grid(
columns: 2,
grid.footer([a]),
[b],
)
---
// Error: 4:3-4:19 footer would conflict with a cell placed before it at column 1 row 0
// Hint: 4:3-4:19 try reducing that cell's rowspan or moving the footer
#grid(
columns: 2,
grid.header(),
grid.footer([a]),
grid.cell(x: 1, y: 0, rowspan: 2)[a],
)
---
// Error: 4:3-4:19 cannot have more than one footer
#grid(
[a],
grid.footer([a]),
grid.footer([b]),
)
---
// Error: 3:3-3:20 cannot use `table.footer` as a grid footer; use `grid.footer` instead
#grid(
[a],
table.footer([a]),
)
---
// Error: 3:3-3:19 cannot use `grid.footer` as a table footer; use `table.footer` instead
#table(
[a],
grid.footer([a]),
)
---
// Error: 14-28 cannot place a grid footer within another footer or header
#grid.header(grid.footer[a])
---
// Error: 14-29 cannot place a table footer within another footer or header
#grid.header(table.footer[a])
---
// Error: 15-29 cannot place a grid footer within another footer or header
#table.header(grid.footer[a])
---
// Error: 15-30 cannot place a table footer within another footer or header
#table.header(table.footer[a])
---
// Error: 14-28 cannot place a grid footer within another footer or header
#grid.footer(grid.footer[a])
---
// Error: 14-29 cannot place a table footer within another footer or header
#grid.footer(table.footer[a])
---
// Error: 15-29 cannot place a grid footer within another footer or header
#table.footer(grid.footer[a])
---
// Error: 15-30 cannot place a table footer within another footer or header
#table.footer(table.footer[a])
---
// Error: 14-28 cannot place a grid header within another header or footer
#grid.footer(grid.header[a])
---
// Error: 14-29 cannot place a table header within another header or footer
#grid.footer(table.header[a])
---
// Error: 15-29 cannot place a grid header within another header or footer
#table.footer(grid.header[a])
---
// Error: 15-30 cannot place a table header within another header or footer
#table.footer(table.header[a])

View File

@ -0,0 +1,31 @@
#set page(height: 17em)
#table(
rows: (auto, 2.5em, auto),
table.header[*Hello*][*World*],
block(width: 2em, height: 10em, fill: red),
table.footer[*Bye*][*World*],
)
---
// Rowspan sizing algorithm doesn't do the best job at non-contiguous content
// ATM.
#set page(height: 20em)
#table(
rows: (auto, 2.5em, 2em, auto, 5em, 2em, 2.5em),
table.header[*Hello*][*World*],
table.cell(rowspan: 3, lorem(20)),
table.footer[*Ok*][*Bye*],
)
---
// This should look right
#set page(height: 20em)
#table(
rows: (auto, 2.5em, 2em, auto),
gutter: 3pt,
table.header[*Hello*][*World*],
table.cell(rowspan: 3, lorem(20)),
table.footer[*Ok*][*Bye*],
)

View File

@ -0,0 +1,44 @@
// Test lack of space for header + text.
#set page(height: 9em + 2.5em + 1.5em)
#table(
rows: (auto, 2.5em, auto, auto, 10em, 2.5em, auto),
gutter: 3pt,
table.header[*Hello*][*World*],
table.cell(rowspan: 3, lorem(30)),
table.footer[*Ok*][*Bye*],
)
---
// Orphan header prevention test
#set page(height: 13em)
#v(8em)
#grid(
columns: 3,
gutter: 5pt,
grid.header(
[*Mui*], [*A*], grid.cell(rowspan: 2, fill: orange)[*B*],
[*Header*], [*Header* #v(0.1em)],
),
..([Test], [Test], [Test]) * 7,
grid.footer(
[*Mui*], [*A*], grid.cell(rowspan: 2, fill: orange)[*B*],
[*Footer*], [*Footer* #v(0.1em)],
),
)
---
// Empty footer should just be a repeated blank row
#set page(height: 8em)
#table(
columns: 4,
align: center + horizon,
table.header(),
..range(0, 2).map(i => (
[John \##i],
table.cell(stroke: green)[123],
table.cell(stroke: blue)[456],
[789]
)).flatten(),
table.footer(),
)

View File

@ -0,0 +1,42 @@
// When a footer has a rowspan with an empty row, it should be displayed
// properly
#set page(height: 14em, width: auto)
#let count = counter("g")
#table(
rows: (auto, 2em, auto, auto),
table.header(
[eeec],
table.cell(rowspan: 2, count.step() + count.display()),
),
[d],
block(width: 5em, fill: yellow, lorem(7)),
[d],
table.footer(
[eeec],
table.cell(rowspan: 2, count.step() + count.display()),
)
)
#count.display()
---
// Nested table with footer should repeat both footers
#set page(height: 10em, width: auto)
#table(
table(
[a\ b\ c\ d],
table.footer[b],
),
table.footer[a],
)
---
#set page(height: 12em, width: auto)
#table(
[a\ b\ c\ d],
table.footer(table(
[c],
[d],
table.footer[b],
))
)

View File

@ -0,0 +1,28 @@
// General footer-only tests
#set page(height: 9em)
#table(
columns: 2,
[a], [],
[b], [],
[c], [],
[d], [],
[e], [],
table.footer(
[*Ok*], table.cell(rowspan: 2)[test],
[*Thanks*]
)
)
---
#set page(height: 5em)
#table(
table.footer[a][b][c]
)
---
#table(table.footer[a][b][c])
#table(
gutter: 3pt,
table.footer[a][b][c]
)

View File

@ -146,17 +146,17 @@
) )
--- ---
// Error: 14-28 cannot place a grid header within another header // Error: 14-28 cannot place a grid header within another header or footer
#grid.header(grid.header[a]) #grid.header(grid.header[a])
--- ---
// Error: 14-29 cannot place a table header within another header // Error: 14-29 cannot place a table header within another header or footer
#grid.header(table.header[a]) #grid.header(table.header[a])
--- ---
// Error: 15-29 cannot place a grid header within another header // Error: 15-29 cannot place a grid header within another header or footer
#table.header(grid.header[a]) #table.header(grid.header[a])
--- ---
// Error: 15-30 cannot place a table header within another header // Error: 15-30 cannot place a table header within another header or footer
#table.header(table.header[a]) #table.header(table.header[a])