summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPgBiel <9021226+PgBiel@users.noreply.github.com>2024-03-09 12:48:48 -0300
committerGitHub <noreply@github.com>2024-03-09 15:48:48 +0000
commit639a8d0dc01171130c84903b512c200c659bc537 (patch)
tree6aa9271588cd12a284eed1bebe4072bce5f34cd8
parentd927974bb13bac9205fd4e90192f1bf7b4b972a3 (diff)
Repeatable Table Footers [More Flexible Tables Pt.6a] (#3577)
Co-authored-by: Laurenz <laurmaedje@gmail.com>
-rw-r--r--crates/typst/src/layout/grid/layout.rs528
-rw-r--r--crates/typst/src/layout/grid/lines.rs40
-rw-r--r--crates/typst/src/layout/grid/mod.rs41
-rw-r--r--crates/typst/src/layout/grid/rowspans.rs190
-rw-r--r--crates/typst/src/model/table.rs45
-rw-r--r--tests/ref/layout/grid-footers-1.pngbin0 -> 50791 bytes
-rw-r--r--tests/ref/layout/grid-footers-2.pngbin0 -> 52405 bytes
-rw-r--r--tests/ref/layout/grid-footers-3.pngbin0 -> 52783 bytes
-rw-r--r--tests/ref/layout/grid-footers-4.pngbin0 -> 20513 bytes
-rw-r--r--tests/ref/layout/grid-footers-5.pngbin0 -> 5769 bytes
-rw-r--r--tests/typ/layout/grid-footers-1.typ192
-rw-r--r--tests/typ/layout/grid-footers-2.typ31
-rw-r--r--tests/typ/layout/grid-footers-3.typ44
-rw-r--r--tests/typ/layout/grid-footers-4.typ42
-rw-r--r--tests/typ/layout/grid-footers-5.typ28
-rw-r--r--tests/typ/layout/grid-headers-1.typ8
16 files changed, 1007 insertions, 182 deletions
diff --git a/crates/typst/src/layout/grid/layout.rs b/crates/typst/src/layout/grid/layout.rs
index c18a5035..354e6814 100644
--- a/crates/typst/src/layout/grid/layout.rs
+++ b/crates/typst/src/layout/grid/layout.rs
@@ -248,6 +248,40 @@ pub(super) struct Header {
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 line or a cell.
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.
pub enum ResolvableGridChild<T: ResolvableCell, I> {
Header { repeat: bool, span: Span, items: I },
+ Footer { repeat: bool, span: Span, items: I },
Item(ResolvableGridItem<T>),
}
@@ -340,7 +375,9 @@ pub struct CellGrid {
/// Contains up to 'rows_without_gutter.len() + 1' vectors of lines.
pub(super) hlines: Vec<Vec<Line>>,
/// 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.
pub(super) has_gutter: bool,
}
@@ -353,7 +390,7 @@ impl CellGrid {
cells: impl IntoIterator<Item = Cell>,
) -> Self {
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.
@@ -398,6 +435,11 @@ impl CellGrid {
let mut header: Option<Header> = None;
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
// an auto row.
let resolve_breakable = |y, rowspan| {
@@ -447,19 +489,20 @@ impl CellGrid {
let mut resolved_cells: Vec<Option<Entry>> = Vec::with_capacity(child_count);
for child in children {
let mut is_header = false;
- let mut header_start = usize::MAX;
- let mut header_end = 0;
- let mut header_span = Span::detached();
+ let mut is_footer = false;
+ let mut child_start = usize::MAX;
+ let mut child_end = 0;
+ let mut child_span = Span::detached();
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, .. } => {
if header.is_some() {
bail!(span, "cannot have more than one header");
}
is_header = true;
- header_span = span;
+ child_span = span;
repeat_header = repeat;
// If any cell in the header is automatically positioned,
@@ -472,9 +515,30 @@ impl CellGrid {
(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)),
};
- 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 {
let cell = match item {
ResolvableGridItem::HLine {
@@ -505,7 +569,7 @@ impl CellGrid {
// minimum it should have for the current grid
// child. Effectively, this means that a hline at
// the start of a header will always appear above
- // that header's first row.
+ // that header's first row. Similarly for footers.
auto_index
.max(min_auto_index)
.checked_sub(1)
@@ -560,7 +624,7 @@ impl CellGrid {
// index. For example, this means that a vline at
// the beginning of a header will be placed to its
// left rather than after the previous
- // automatically positioned cell.
+ // automatically positioned cell. Same for footers.
auto_index
.checked_sub(1)
.filter(|last_auto_index| {
@@ -706,25 +770,30 @@ impl CellGrid {
}
}
- if is_header {
- // Ensure each cell in a header is fully contained within
- // the header.
- header_start = header_start.min(y);
- header_end = header_end.max(y + rowspan);
+ if is_header || is_footer {
+ // Ensure each cell in a header or footer is fully
+ // contained within it.
+ child_start = child_start.min(y);
+ child_end = child_end.max(y + rowspan);
}
}
- if is_header {
- if header_start == usize::MAX {
- // 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 (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 header_start != 0 {
+ if is_header {
+ if child_start != 0 {
bail!(
- header_span,
+ child_span,
"header must start at the first row";
hint: "remove any rows before the header"
);
@@ -735,9 +804,29 @@ impl CellGrid {
// is gutter. But only once all cells have been analyzed
// and the header has fully expanded in the fixup loop
// 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.
// FIXME: Consider only doing this if the header has any fully
// automatically positioned cells. Otherwise,
@@ -751,7 +840,7 @@ impl CellGrid {
// course.
// None of the above are concerns for now, as headers must
// start at the first row.
- auto_index = auto_index.max(c * header_end);
+ auto_index = auto_index.max(c * child_end);
}
}
@@ -760,27 +849,55 @@ impl CellGrid {
// vector of 'Entry' from 'Option<Entry>'.
// 2. If any cells were added to the header's rows after the header's
// 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
.into_iter()
.enumerate()
.map(|(i, cell)| {
if let Some(cell) = cell {
- if let Some((parent_cell, header)) =
- cell.as_cell().zip(header.as_mut())
- {
- let y = i / c;
- if y < header.end {
- // Ensure the header expands enough such that all
- // cells inside it, even those added later, are
- // fully contained within the header.
- // FIXME: check if start < y < end when start can
- // be != 0.
- // FIXME: when start can be != 0, decide what
- // happens when a cell after the header placed
- // above it tries to span the header (either error
- // or expand upwards).
- header.end = header.end.max(y + parent_cell.rowspan.get());
+ if let Some(parent_cell) = cell.as_cell() {
+ if let Some(header) = &mut header
+ {
+ let y = i / c;
+ if y < header.end {
+ // Ensure the header expands enough such that
+ // all cells inside it, even those added later,
+ // are fully contained within the header.
+ // FIXME: check if start < y < end when start can
+ // be != 0.
+ // FIXME: when start can be != 0, decide what
+ // happens when a cell after the header placed
+ // above it tries to span the header (either
+ // error or expand upwards).
+ 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);
+ }
}
}
@@ -888,36 +1005,87 @@ impl CellGrid {
vlines[x].push(line);
}
- // No point in storing the header if it shouldn't be repeated.
- let header = header.filter(|_| repeat_header).map(|mut header| {
- // Repeat the gutter below a header (hence why we don't
- // subtract 1 from the gutter case).
- // Don't do this if there are no rows under the header.
- if has_gutter {
- // - 'header.end' is always 'last y + 1'. The header stops
- // before that row.
- // - Therefore, '2 * header.end' will be 2 * (last y + 1),
- // which is the adjusted index of the row before which the
- // header stops, meaning it will still stop right before it
- // even with gutter thanks to the multiplication below.
- // - This means that it will span all rows up to
- // '2 * (last y + 1) - 1 = 2 * last y + 1', which equates to
- // the index of the gutter row right below the header, which is
- // what we want (that gutter spacing should be repeated across
- // pages to maintain uniformity).
- header.end *= 2;
-
- // If the header occupies the entire grid, ensure we don't
- // include an extra gutter row when it doesn't exist, since
- // the last row of the header is at the very bottom, therefore
- // '2 * last y + 1' is not a valid index.
- let row_amount = (2 * row_amount).saturating_sub(1);
- header.end = header.end.min(row_amount);
- }
- header
- });
+ let header = header
+ .map(|mut header| {
+ // Repeat the gutter below a header (hence why we don't
+ // subtract 1 from the gutter case).
+ // Don't do this if there are no rows under the header.
+ if has_gutter {
+ // - 'header.end' is always 'last y + 1'. The header stops
+ // before that row.
+ // - Therefore, '2 * header.end' will be 2 * (last y + 1),
+ // which is the adjusted index of the row before which the
+ // header stops, meaning it will still stop right before it
+ // even with gutter thanks to the multiplication below.
+ // - This means that it will span all rows up to
+ // '2 * (last y + 1) - 1 = 2 * last y + 1', which equates
+ // to the index of the gutter row right below the header,
+ // which is what we want (that gutter spacing should be
+ // repeated across pages to maintain uniformity).
+ header.end *= 2;
+
+ // If the header occupies the entire grid, ensure we don't
+ // include an extra gutter row when it doesn't exist, since
+ // the last row of the header is at the very bottom,
+ // therefore '2 * last y + 1' is not a valid index.
+ let row_amount = (2 * row_amount).saturating_sub(1);
+ header.end = header.end.min(row_amount);
+ }
+ header
+ })
+ .map(|header| {
+ if repeat_header {
+ Repeatable::Repeated(header)
+ } else {
+ Repeatable::NotRepeated(header)
+ }
+ });
+
+ 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;
- Ok(Self::new_internal(tracks, gutter, vlines, hlines, header, resolved_cells))
+ // 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.
@@ -926,7 +1094,8 @@ impl CellGrid {
gutter: Axes<&[Sizing]>,
vlines: Vec<Vec<Line>>,
hlines: Vec<Vec<Line>>,
- header: Option<Header>,
+ header: Option<Repeatable<Header>>,
+ footer: Option<Repeatable<Footer>>,
entries: Vec<Entry>,
) -> Self {
let mut cols = vec![];
@@ -980,6 +1149,7 @@ impl CellGrid {
vlines,
hlines,
header,
+ footer,
has_gutter,
}
}
@@ -1239,6 +1409,9 @@ pub struct GridLayouter<'a> {
/// header rows themselves are unbreakable, and unbreakable rows do not
/// need to read this field at all.
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.
pub(super) span: Span,
}
@@ -1303,6 +1476,7 @@ impl<'a> GridLayouter<'a> {
finished: vec![],
is_rtl: TextElem::dir_in(styles) == Dir::RTL,
header_height: Abs::zero(),
+ footer_height: Abs::zero(),
span,
}
}
@@ -1311,17 +1485,37 @@ impl<'a> GridLayouter<'a> {
pub fn layout(mut self, engine: &mut Engine) -> SourceResult<Fragment> {
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() {
- if let Some(header) = &self.grid.header {
+ if let Some(Repeatable::Repeated(header)) = &self.grid.header {
if y < header.end {
if y == 0 {
self.layout_header(header, engine)?;
+ self.regions.size.y -= self.footer_height;
}
// Skip header rows during normal layout.
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)?;
}
@@ -1550,6 +1744,7 @@ impl<'a> GridLayouter<'a> {
.grid
.header
.as_ref()
+ .and_then(Repeatable::as_repeated)
.is_some_and(|header| prev_y + 1 == header.end)
})
.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 previous row, so they function similarly to 'prev_lines'.
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)
{
if prev_y + 1 != y
@@ -2053,7 +2248,13 @@ impl<'a> GridLayouter<'a> {
let frame = self.layout_single_row(engine, first, y)?;
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.
self.header_height += first;
}
@@ -2071,10 +2272,16 @@ impl<'a> GridLayouter<'a> {
.zip(&mut resolved[..len - 1])
.skip(self.lrows.iter().any(|row| matches!(row, Row::Fr(..))) as usize)
{
- // Subtract header height from the region height when it's not the
- // first.
- target
- .set_max(region.y - if i > 0 { self.header_height } else { Abs::zero() });
+ // Subtract header and footer heights from the region height when
+ // it's not the first.
+ target.set_max(
+ region.y
+ - if i > 0 {
+ self.header_height + self.footer_height
+ } else {
+ Abs::zero()
+ },
+ );
}
// 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 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.
self.header_height += resolved;
}
// 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
- // properly considers that a header would be added on each region
- // break.
+ // properly considers that a header and a footer would be added on each
+ // region break.
let height = frame.height();
while self.unbreakable_rows_left == 0
&& !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)?;
@@ -2421,14 +2634,52 @@ impl<'a> GridLayouter<'a> {
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
+ && 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)
- && !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
- // rows beyond the header. Push an empty region.
+ // Header and footer would be alone in this region, but there are more
+ // rows beyond the header and the footer. Push an empty region.
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).
// Any rowspans ending before this row are laid out even
// on this row's first frame.
- if rowspan.y + rowspan.rowspan < y + 1
- || rowspan.y + rowspan.rowspan == y + 1 && is_last
+ if laid_out_footer_start.map_or(true, |footer_start| {
+ // 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
// it from the rowspans vector and lay it out.
@@ -2554,11 +2809,18 @@ impl<'a> GridLayouter<'a> {
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.
self.layout_header(header, engine)?;
}
+ // Ensure rows don't try to overrun the footer.
+ self.regions.size.y -= self.footer_height;
+
Ok(())
}
@@ -2579,18 +2841,30 @@ impl<'a> GridLayouter<'a> {
engine: &mut Engine,
) -> SourceResult<()> {
let header_rows = self.simulate_header(header, &self.regions, engine)?;
+ let mut skipped_region = false;
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()
{
// 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![]);
+ skipped_region = true;
}
// Reset the header height for this region.
+ // It will be re-calculated when laying out each header row.
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.
// Thus, no risk of 'finish_region' being recursively called from
// within 'layout_row'.
@@ -2618,6 +2892,78 @@ impl<'a> GridLayouter<'a> {
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,
diff --git a/crates/typst/src/layout/grid/lines.rs b/crates/typst/src/layout/grid/lines.rs
index b659ba77..1249c3e9 100644
--- a/crates/typst/src/layout/grid/lines.rs
+++ b/crates/typst/src/layout/grid/lines.rs
@@ -1,7 +1,7 @@
use std::num::NonZeroUsize;
use std::sync::Arc;
-use super::layout::{CellGrid, RowPiece};
+use super::layout::{CellGrid, Repeatable, RowPiece};
use crate::foundations::{AlternativeFold, Fold};
use crate::layout::Abs;
use crate::visualize::Stroke;
@@ -538,19 +538,33 @@ pub(super) fn hline_stroke_at_column(
// Top border stroke and header stroke are generally prioritized, unless
// they don't have explicit hline overrides and one or more user-provided
// hlines would appear at the same position, which then are prioritized.
- let top_stroke_comes_from_header =
- grid.header
- .as_ref()
- .zip(local_top_y)
- .is_some_and(|(header, local_top_y)| {
- // Ensure the row above us is a repeated header.
- // FIXME: Make this check more robust when headers at arbitrary
- // positions are added.
- local_top_y + 1 == header.end && y != header.end
- });
+ let top_stroke_comes_from_header = grid
+ .header
+ .as_ref()
+ .and_then(Repeatable::as_repeated)
+ .zip(local_top_y)
+ .is_some_and(|(header, local_top_y)| {
+ // Ensure the row above us is a repeated header.
+ // FIXME: Make this check more robust when headers at arbitrary
+ // positions are added.
+ 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) =
if !use_bottom_border_stroke
+ && !bottom_stroke_comes_from_footer
&& (use_top_border_stroke
|| top_stroke_comes_from_header
|| 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
// prioritizing the bottom cell's top stroke.
// Additionally, the bottom border cell's stroke always has
- // priority.
+ // priority. Same for stroke above footers.
(bottom_cell_stroke, top_cell_stroke)
};
@@ -658,6 +672,7 @@ mod test {
vec![],
vec![],
None,
+ None,
entries,
)
}
@@ -1195,6 +1210,7 @@ mod test {
vec![],
vec![],
None,
+ None,
entries,
)
}
diff --git a/crates/typst/src/layout/grid/mod.rs b/crates/typst/src/layout/grid/mod.rs
index 0f431bc6..b58b5054 100644
--- a/crates/typst/src/layout/grid/mod.rs
+++ b/crates/typst/src/layout/grid/mod.rs
@@ -23,7 +23,7 @@ use crate::layout::{
Abs, AlignElem, Alignment, Axes, Dir, Fragment, LayoutMultiple, Length,
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::text::TextElem;
use crate::util::NonZeroExt;
@@ -299,6 +299,9 @@ impl GridElem {
#[elem]
type GridHeader;
+
+ #[elem]
+ type GridFooter;
}
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());
// Use trace to link back to the grid when a specific cell errors
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 {
GridChild::Header(header) => ResolvableGridChild::Header {
repeat: header.repeat(styles),
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) => {
ResolvableGridChild::Item(item.to_resolvable(styles))
@@ -369,6 +378,7 @@ cast! {
#[derive(Debug, PartialEq, Clone, Hash)]
pub enum GridChild {
Header(Packed<GridHeader>),
+ Footer(Packed<GridFooter>),
Item(GridItem),
}
@@ -376,6 +386,7 @@ cast! {
GridChild,
self => match self {
Self::Header(header) => header.into_value(),
+ Self::Footer(footer) => footer.into_value(),
Self::Item(item) => item.into_value(),
},
v: Content => {
@@ -389,10 +400,14 @@ impl TryFrom<Content> for GridChild {
if value.is::<TableHeader>() {
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
.into_packed::<GridHeader>()
.map(Self::Header)
+ .or_else(|value| value.into_packed::<GridFooter>().map(Self::Footer))
.or_else(|value| GridItem::try_from(value).map(Self::Item))
}
}
@@ -459,10 +474,16 @@ impl TryFrom<Content> for GridItem {
type Error = EcoString;
fn try_from(value: Content) -> StrResult<Self> {
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>() {
- 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>() {
bail!("cannot use `table.cell` as a grid cell; use `grid.cell` instead");
@@ -498,6 +519,18 @@ pub struct GridHeader {
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.
///
/// Overrides any per-cell stroke, including stroke specified through the
diff --git a/crates/typst/src/layout/grid/rowspans.rs b/crates/typst/src/layout/grid/rowspans.rs
index d33b7943..764a2b70 100644
--- a/crates/typst/src/layout/grid/rowspans.rs
+++ b/crates/typst/src/layout/grid/rowspans.rs
@@ -6,7 +6,7 @@ use crate::layout::{
};
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.
pub(super) struct Rowspan {
@@ -132,7 +132,7 @@ impl<'a> GridLayouter<'a> {
// The rowspan continuation starts after the header (thus,
// at a position after the sum of the laid out header
// rows).
- if let Some(header) = &self.grid.header {
+ if let Some(Repeatable::Repeated(header)) = &self.grid.header {
let header_rows = self
.rrows
.get(i)
@@ -194,16 +194,36 @@ impl<'a> GridLayouter<'a> {
engine: &mut Engine,
) -> SourceResult<()> {
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(
current_row,
- None,
+ amount_unbreakable_rows,
&self.regions,
engine,
)?;
// Skip to fitting region.
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)?;
}
@@ -305,27 +325,31 @@ impl<'a> GridLayouter<'a> {
let rowspan = self.grid.effective_rowspan_of_cell(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
- // the heights from previous regions to the current backlog to form
- // a rowspan's expected backlog. We also subtract the header's
- // height from all regions.
+ // is a rowspan, or if headers or footers are used. When measuring, we
+ // join the heights from previous regions to the current backlog to
+ // form a rowspan's expected backlog. We also subtract the header's
+ // and footer's heights from all regions.
let mut custom_backlog: Vec<Abs> = vec![];
- // This function is used to subtract the expected header height from
- // each upcoming region size in the current backlog and last region.
- let mut subtract_header_height_from_regions = || {
+ // This function is used to subtract the expected header and footer
+ // height from each upcoming region size in the current backlog and
+ // last region.
+ let mut subtract_header_footer_height_from_regions = || {
// Only breakable auto rows need to update their backlogs based
- // on the presence of a header, given that unbreakable auto
- // rows don't depend on the backlog, as they only span one
+ // on the presence of a header or footer, given that unbreakable
+ // auto rows don't depend on the backlog, as they only span one
// region.
- if breakable && self.grid.header.is_some() {
- // Subtract header height from all upcoming regions when
- // measuring the cell, including the last repeated region.
+ if breakable
+ && (matches!(self.grid.header, Some(Repeatable::Repeated(_)))
+ || 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
// updated heights of the upcoming regions.
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
@@ -365,13 +389,13 @@ impl<'a> GridLayouter<'a> {
// However, if the auto row is unbreakable, measure with infinite
// height instead to see how much content expands.
// 2. Use the region's backlog and last region when measuring,
- // however subtract the expected header height from each upcoming
- // size, if there is a header.
+ // however subtract the expected header and footer heights from
+ // each upcoming size, if there is a header or footer.
// 3. Use the same full region height.
// 4. No height occupied by this cell in this region so far.
// 5. Yes, this cell started in this region.
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() };
height_in_this_region = Abs::zero();
frames_in_previous_regions = 0;
@@ -426,7 +450,7 @@ impl<'a> GridLayouter<'a> {
.iter()
.copied()
.chain(std::iter::once(if breakable {
- self.initial.y - self.header_height
+ self.initial.y - self.header_height - self.footer_height
} else {
// When measuring unbreakable auto rows, infinite
// height is available for content to expand.
@@ -442,7 +466,7 @@ impl<'a> GridLayouter<'a> {
.regions
.backlog
.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<_>>()
} else {
@@ -456,7 +480,10 @@ impl<'a> GridLayouter<'a> {
height = *rowspan_height;
backlog = None;
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 {
// The rowspan started in the current region, as its vector
// of heights in regions is currently empty.
@@ -472,7 +499,7 @@ impl<'a> GridLayouter<'a> {
} 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() };
frames_in_previous_regions = 0;
}
@@ -655,10 +682,10 @@ impl<'a> GridLayouter<'a> {
// resolved vector, above.
simulated_regions.next();
- // Subtract the initial header height, since that's the height we
- // used when subtracting from the region backlog's heights while
- // measuring cells.
- simulated_regions.size.y -= self.header_height;
+ // Subtract the initial header and footer height, since that's the
+ // height we used when subtracting from the region backlog's
+ // heights while measuring cells.
+ simulated_regions.size.y -= self.header_height + self.footer_height;
}
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
// of the requested rowspan height, we give up.
for _attempt in 0..5 {
- let rowspan_simulator =
- RowspanSimulator::new(simulated_regions, self.header_height);
+ let rowspan_simulator = RowspanSimulator::new(
+ simulated_regions,
+ self.header_height,
+ self.footer_height,
+ );
let total_spanned_height = rowspan_simulator.simulate_rowspan_layout(
y,
@@ -871,7 +901,7 @@ impl<'a> GridLayouter<'a> {
{
extra_amount_to_grow -= simulated_regions.size.y.max(Abs::zero());
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;
}
@@ -887,6 +917,8 @@ struct RowspanSimulator<'a> {
regions: Regions<'a>,
/// The height of the header in the currently simulated region.
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.
total_spanned_height: Abs,
/// Height of the latest spanned gutter row in the simulation.
@@ -896,11 +928,12 @@ struct RowspanSimulator<'a> {
impl<'a> RowspanSimulator<'a> {
/// Creates new rowspan simulation state with the given regions and initial
- /// header height. Other fields should always start as zero.
- fn new(regions: Regions<'a>, header_height: Abs) -> Self {
+ /// header and footer heights. Other fields should always start as zero.
+ fn new(regions: Regions<'a>, header_height: Abs, footer_height: Abs) -> Self {
Self {
regions,
header_height,
+ footer_height,
total_spanned_height: Abs::zero(),
latest_spanned_gutter_height: Abs::zero(),
}
@@ -948,7 +981,10 @@ impl<'a> RowspanSimulator<'a> {
engine,
)?;
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)?;
}
@@ -970,7 +1006,10 @@ impl<'a> RowspanSimulator<'a> {
let mut skipped_region = false;
while unbreakable_rows_left == 0
&& !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)?;
@@ -1002,50 +1041,69 @@ impl<'a> RowspanSimulator<'a> {
Ok(self.total_spanned_height)
}
- fn simulate_header_layout(
+ fn simulate_header_footer_layout(
&mut self,
layouter: &GridLayouter<'_>,
engine: &mut Engine,
) -> SourceResult<()> {
- if let Some(header) = &layouter.grid.header {
- // We can't just use the initial header height on each
- // region, because header height might vary depending
- // on region size if it contains rows with relative
- // lengths. Therefore, we re-simulate headers on each
- // new region.
- // It's true that, when measuring cells, we reduce each
- // height in the backlog to consider the initial header
- // height; however, our simulation checks what happens
- // AFTER the auto row, so we can just use the original
- // backlog from `self.regions`.
- let header_row_group =
- layouter.simulate_header(header, &self.regions, engine)?;
- let mut skipped_region = false;
-
- // Skip until we reach a fitting region for this header.
- while !self.regions.size.y.fits(header_row_group.height)
- && !self.regions.in_last()
- {
- self.regions.next();
- skipped_region = true;
- }
+ // We can't just use the initial header/footer height on each region,
+ // because header/footer height might vary depending on region size if
+ // it contains rows with relative lengths. Therefore, we re-simulate
+ // headers and footers on each new region.
+ // It's true that, when measuring cells, we reduce each height in the
+ // backlog to consider the initial header and footer heights; however,
+ // our simulation checks what happens AFTER the auto row, so we can
+ // just use the original backlog from `self.regions`.
+ let header_height =
+ if let Some(Repeatable::Repeated(header)) = &layouter.grid.header {
+ layouter.simulate_header(header, &self.regions, engine)?.height
+ } else {
+ 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;
+
+ // Skip until we reach a fitting region for both header and footer.
+ while !self.regions.size.y.fits(header_height + footer_height)
+ && !self.regions.in_last()
+ {
+ self.regions.next();
+ skipped_region = true;
+ }
+
+ if let Some(Repeatable::Repeated(header)) = &layouter.grid.header {
self.header_height = if skipped_region {
// Simulate headers again, at the new region, as
// the full region height may change.
layouter.simulate_header(header, &self.regions, engine)?.height
} 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(())
}
@@ -1060,7 +1118,7 @@ impl<'a> RowspanSimulator<'a> {
self.latest_spanned_gutter_height = Abs::zero();
self.regions.next();
- self.simulate_header_layout(layouter, engine)
+ self.simulate_header_footer_layout(layouter, engine)
}
}
diff --git a/crates/typst/src/model/table.rs b/crates/typst/src/model/table.rs
index 250a527c..c3bf3dbc 100644
--- a/crates/typst/src/model/table.rs
+++ b/crates/typst/src/model/table.rs
@@ -10,8 +10,8 @@ use crate::foundations::{
};
use crate::layout::{
show_grid_cell, Abs, Alignment, Axes, Cell, CellGrid, Celled, Dir, Fragment,
- GridCell, GridHLine, GridHeader, GridLayouter, GridVLine, LayoutMultiple, Length,
- LinePosition, OuterHAlignment, OuterVAlignment, Regions, Rel, ResolvableCell,
+ GridCell, GridFooter, GridHLine, GridHeader, GridLayouter, GridVLine, LayoutMultiple,
+ Length, LinePosition, OuterHAlignment, OuterVAlignment, Regions, Rel, ResolvableCell,
ResolvableGridChild, ResolvableGridItem, Sides, TrackSizings,
};
use crate::model::Figurable;
@@ -224,6 +224,9 @@ impl TableElem {
#[elem]
type TableHeader;
+
+ #[elem]
+ type TableFooter;
}
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());
// Use trace to link back to the table when a specific cell errors
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 {
TableChild::Header(header) => ResolvableGridChild::Header {
repeat: header.repeat(styles),
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) => {
ResolvableGridChild::Item(item.to_resolvable(styles))
@@ -319,6 +328,7 @@ impl Figurable for Packed<TableElem> {}
#[derive(Debug, PartialEq, Clone, Hash)]
pub enum TableChild {
Header(Packed<TableHeader>),
+ Footer(Packed<TableFooter>),
Item(TableItem),
}
@@ -326,6 +336,7 @@ cast! {
TableChild,
self => match self {
Self::Header(header) => header.into_value(),
+ Self::Footer(footer) => footer.into_value(),
Self::Item(item) => item.into_value(),
},
v: Content => {
@@ -342,10 +353,16 @@ impl TryFrom<Content> for TableChild {
"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
.into_packed::<TableHeader>()
.map(Self::Header)
+ .or_else(|value| value.into_packed::<TableFooter>().map(Self::Footer))
.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> {
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>() {
- 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>() {
bail!("cannot use `grid.cell` as a table cell; use `table.cell` instead");
@@ -452,6 +475,18 @@ pub struct TableHeader {
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
/// [`grid.hline`]($grid.hline) for more information regarding how to use this
/// element's fields.
diff --git a/tests/ref/layout/grid-footers-1.png b/tests/ref/layout/grid-footers-1.png
new file mode 100644
index 00000000..331cf7ad
--- /dev/null
+++ b/tests/ref/layout/grid-footers-1.png
Binary files differ
diff --git a/tests/ref/layout/grid-footers-2.png b/tests/ref/layout/grid-footers-2.png
new file mode 100644
index 00000000..60e9689c
--- /dev/null
+++ b/tests/ref/layout/grid-footers-2.png
Binary files differ
diff --git a/tests/ref/layout/grid-footers-3.png b/tests/ref/layout/grid-footers-3.png
new file mode 100644
index 00000000..cc4948b8
--- /dev/null
+++ b/tests/ref/layout/grid-footers-3.png
Binary files differ
diff --git a/tests/ref/layout/grid-footers-4.png b/tests/ref/layout/grid-footers-4.png
new file mode 100644
index 00000000..29a6430b
--- /dev/null
+++ b/tests/ref/layout/grid-footers-4.png
Binary files differ
diff --git a/tests/ref/layout/grid-footers-5.png b/tests/ref/layout/grid-footers-5.png
new file mode 100644
index 00000000..b58ed266
--- /dev/null
+++ b/tests/ref/layout/grid-footers-5.png
Binary files differ
diff --git a/tests/typ/layout/grid-footers-1.typ b/tests/typ/layout/grid-footers-1.typ
new file mode 100644
index 00000000..c7a59e60
--- /dev/null
+++ b/tests/typ/layout/grid-footers-1.typ
@@ -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])
diff --git a/tests/typ/layout/grid-footers-2.typ b/tests/typ/layout/grid-footers-2.typ
new file mode 100644
index 00000000..df333434
--- /dev/null
+++ b/tests/typ/layout/grid-footers-2.typ
@@ -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*],
+)
diff --git a/tests/typ/layout/grid-footers-3.typ b/tests/typ/layout/grid-footers-3.typ
new file mode 100644
index 00000000..070500f1
--- /dev/null
+++ b/tests/typ/layout/grid-footers-3.typ
@@ -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(),
+)
diff --git a/tests/typ/layout/grid-footers-4.typ b/tests/typ/layout/grid-footers-4.typ
new file mode 100644
index 00000000..b6d978e9
--- /dev/null
+++ b/tests/typ/layout/grid-footers-4.typ
@@ -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],
+ ))
+)
diff --git a/tests/typ/layout/grid-footers-5.typ b/tests/typ/layout/grid-footers-5.typ
new file mode 100644
index 00000000..db2489fd
--- /dev/null
+++ b/tests/typ/layout/grid-footers-5.typ
@@ -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]
+)
diff --git a/tests/typ/layout/grid-headers-1.typ b/tests/typ/layout/grid-headers-1.typ
index f1afe70e..ac998029 100644
--- a/tests/typ/layout/grid-headers-1.typ
+++ b/tests/typ/layout/grid-headers-1.typ
@@ -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])
---
-// 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])
---
-// 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])
---
-// 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])