diff --git a/crates/typst/src/layout/grid/layout.rs b/crates/typst/src/layout/grid/layout.rs index c18a50351..354e6814d 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 { + Repeated(T), + NotRepeated(T), +} + +impl Repeatable { + /// 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 { @@ -284,6 +318,7 @@ pub enum ResolvableGridItem { /// Any grid child, which can be either a header or an item. pub enum ResolvableGridChild { Header { repeat: bool, span: Span, items: I }, + Footer { repeat: bool, span: Span, items: I }, Item(ResolvableGridItem), } @@ -340,7 +375,9 @@ pub struct CellGrid { /// Contains up to 'rows_without_gutter.len() + 1' vectors of lines. pub(super) hlines: Vec>, /// The repeatable header of this grid. - pub(super) header: Option
, + pub(super) header: Option>, + /// The repeatable footer of this grid. + pub(super) footer: Option>, /// Whether this grid has gutters. pub(super) has_gutter: bool, } @@ -353,7 +390,7 @@ impl CellGrid { cells: impl IntoIterator, ) -> 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
= 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> = 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 || 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 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 header_start != 0 { + 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'. // 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; + 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 - }); + // 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) + } + }); - 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. @@ -926,7 +1094,8 @@ impl CellGrid { gutter: Axes<&[Sizing]>, vlines: Vec>, hlines: Vec>, - header: Option
, + header: Option>, + footer: Option>, entries: Vec, ) -> 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 { 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 { + // 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 b659ba77d..1249c3e95 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 0f431bc65..b58b50549 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 { @@ -322,11 +325,17 @@ impl LayoutMultiple for Packed { 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), + Footer(Packed), 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 for GridChild { if value.is::() { bail!("cannot use `table.header` as a grid header; use `grid.header` instead") } + if value.is::() { + bail!("cannot use `table.footer` as a grid footer; use `grid.footer` instead") + } value .into_packed::() .map(Self::Header) + .or_else(|value| value.into_packed::().map(Self::Footer)) .or_else(|value| GridItem::try_from(value).map(Self::Item)) } } @@ -459,10 +474,16 @@ impl TryFrom for GridItem { type Error = EcoString; fn try_from(value: Content) -> StrResult { if value.is::() { - bail!("cannot place a grid header within another header"); + bail!("cannot place a grid header within another header or footer"); } if value.is::() { - bail!("cannot place a table header within another header"); + bail!("cannot place a table header within another header or footer"); + } + if value.is::() { + bail!("cannot place a grid footer within another footer or header"); + } + if value.is::() { + bail!("cannot place a table footer within another footer or header"); } if value.is::() { bail!("cannot use `table.cell` as a grid cell; use `grid.cell` instead"); @@ -498,6 +519,18 @@ pub struct GridHeader { pub children: Vec, } +/// 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, +} + /// 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 d33b79434..764a2b702 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 = 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::>() } 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; + // 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() + }; - // 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; - } + 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 250a527cf..c3bf3dbc4 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 { @@ -247,11 +250,17 @@ impl LayoutMultiple for Packed { 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 {} #[derive(Debug, PartialEq, Clone, Hash)] pub enum TableChild { Header(Packed), + Footer(Packed), 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 for TableChild { "cannot use `grid.header` as a table header; use `table.header` instead" ) } + if value.is::() { + bail!( + "cannot use `grid.footer` as a table footer; use `table.footer` instead" + ) + } value .into_packed::() .map(Self::Header) + .or_else(|value| value.into_packed::().map(Self::Footer)) .or_else(|value| TableItem::try_from(value).map(Self::Item)) } } @@ -413,10 +430,16 @@ impl TryFrom for TableItem { fn try_from(value: Content) -> StrResult { if value.is::() { - bail!("cannot place a grid header within another header"); + bail!("cannot place a grid header within another header or footer"); } if value.is::() { - bail!("cannot place a table header within another header"); + bail!("cannot place a table header within another header or footer"); + } + if value.is::() { + bail!("cannot place a grid footer within another footer or header"); + } + if value.is::() { + bail!("cannot place a table footer within another footer or header"); } if value.is::() { bail!("cannot use `grid.cell` as a table cell; use `table.cell` instead"); @@ -452,6 +475,18 @@ pub struct TableHeader { pub children: Vec, } +/// 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, +} + /// 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 000000000..331cf7ad5 Binary files /dev/null and b/tests/ref/layout/grid-footers-1.png differ diff --git a/tests/ref/layout/grid-footers-2.png b/tests/ref/layout/grid-footers-2.png new file mode 100644 index 000000000..60e9689c6 Binary files /dev/null and b/tests/ref/layout/grid-footers-2.png differ diff --git a/tests/ref/layout/grid-footers-3.png b/tests/ref/layout/grid-footers-3.png new file mode 100644 index 000000000..cc4948b80 Binary files /dev/null and b/tests/ref/layout/grid-footers-3.png differ diff --git a/tests/ref/layout/grid-footers-4.png b/tests/ref/layout/grid-footers-4.png new file mode 100644 index 000000000..29a6430bd Binary files /dev/null and b/tests/ref/layout/grid-footers-4.png differ diff --git a/tests/ref/layout/grid-footers-5.png b/tests/ref/layout/grid-footers-5.png new file mode 100644 index 000000000..b58ed2669 Binary files /dev/null and b/tests/ref/layout/grid-footers-5.png differ diff --git a/tests/typ/layout/grid-footers-1.typ b/tests/typ/layout/grid-footers-1.typ new file mode 100644 index 000000000..c7a59e600 --- /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 000000000..df3334349 --- /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 000000000..070500f19 --- /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 000000000..b6d978e9b --- /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 000000000..db2489fd9 --- /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 f1afe70e9..ac9980291 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])