mirror of
https://github.com/typst/typst
synced 2025-05-21 20:45:27 +08:00
Repeatable Table Footers [More Flexible Tables Pt.6a] (#3577)
Co-authored-by: Laurenz <laurmaedje@gmail.com>
This commit is contained in:
parent
d927974bb1
commit
639a8d0dc0
@ -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 || 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,30 +849,58 @@ 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())
|
||||
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.
|
||||
// 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).
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cell)
|
||||
} else {
|
||||
let x = i % c;
|
||||
@ -888,8 +1005,8 @@ 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| {
|
||||
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.
|
||||
@ -901,23 +1018,74 @@ impl CellGrid {
|
||||
// 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).
|
||||
// '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.
|
||||
// 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<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,
|
||||
|
@ -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,9 +538,10 @@ 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
|
||||
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.
|
||||
@ -549,8 +550,21 @@ pub(super) fn hline_stroke_at_column(
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)?;
|
||||
// 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 this header.
|
||||
while !self.regions.size.y.fits(header_row_group.height)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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.
|
||||
|
BIN
tests/ref/layout/grid-footers-1.png
Normal file
BIN
tests/ref/layout/grid-footers-1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 50 KiB |
BIN
tests/ref/layout/grid-footers-2.png
Normal file
BIN
tests/ref/layout/grid-footers-2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 51 KiB |
BIN
tests/ref/layout/grid-footers-3.png
Normal file
BIN
tests/ref/layout/grid-footers-3.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 52 KiB |
BIN
tests/ref/layout/grid-footers-4.png
Normal file
BIN
tests/ref/layout/grid-footers-4.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
BIN
tests/ref/layout/grid-footers-5.png
Normal file
BIN
tests/ref/layout/grid-footers-5.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.6 KiB |
192
tests/typ/layout/grid-footers-1.typ
Normal file
192
tests/typ/layout/grid-footers-1.typ
Normal file
@ -0,0 +1,192 @@
|
||||
#set page(width: auto, height: 15em)
|
||||
#set text(6pt)
|
||||
#set table(inset: 2pt, stroke: 0.5pt)
|
||||
#table(
|
||||
columns: 5,
|
||||
align: center + horizon,
|
||||
table.header(
|
||||
table.cell(colspan: 5)[*Cool Zone*],
|
||||
table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*],
|
||||
table.hline(start: 2, end: 3, stroke: yellow)
|
||||
),
|
||||
..range(0, 5).map(i => ([John \##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten(),
|
||||
table.footer(
|
||||
table.hline(start: 2, end: 3, stroke: yellow),
|
||||
table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*],
|
||||
table.cell(colspan: 5)[*Cool Zone*]
|
||||
)
|
||||
)
|
||||
|
||||
---
|
||||
// Gutter & no repetition
|
||||
#set page(width: auto, height: 16em)
|
||||
#set text(6pt)
|
||||
#set table(inset: 2pt, stroke: 0.5pt)
|
||||
#table(
|
||||
columns: 5,
|
||||
gutter: 2pt,
|
||||
align: center + horizon,
|
||||
table.header(
|
||||
table.cell(colspan: 5)[*Cool Zone*],
|
||||
table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*],
|
||||
table.hline(start: 2, end: 3, stroke: yellow)
|
||||
),
|
||||
..range(0, 5).map(i => ([John \##i], table.cell(stroke: green)[123], table.cell(stroke: blue)[456], [789], [?], table.hline(start: 4, end: 5, stroke: red))).flatten(),
|
||||
table.footer(
|
||||
repeat: false,
|
||||
table.hline(start: 2, end: 3, stroke: yellow),
|
||||
table.cell(stroke: red)[*Name*], table.cell(stroke: aqua)[*Number*], [*Data 1*], [*Data 2*], [*Etc*],
|
||||
table.cell(colspan: 5)[*Cool Zone*]
|
||||
)
|
||||
)
|
||||
|
||||
---
|
||||
#table(
|
||||
table.header(table.cell(stroke: red)[Hello]),
|
||||
table.footer(table.cell(stroke: aqua)[Bye]),
|
||||
)
|
||||
|
||||
---
|
||||
#table(
|
||||
gutter: 3pt,
|
||||
table.header(table.cell(stroke: red)[Hello]),
|
||||
table.footer(table.cell(stroke: aqua)[Bye]),
|
||||
)
|
||||
|
||||
---
|
||||
// Footer's top stroke should win when repeated, but lose at the last page.
|
||||
#set page(height: 10em)
|
||||
#table(
|
||||
stroke: green,
|
||||
table.header(table.cell(stroke: red)[Hello]),
|
||||
table.cell(stroke: yellow)[Hi],
|
||||
table.cell(stroke: yellow)[Bye],
|
||||
table.cell(stroke: yellow)[Ok],
|
||||
table.footer[Bye],
|
||||
)
|
||||
|
||||
---
|
||||
// Relative lengths
|
||||
#set page(height: 10em)
|
||||
#table(
|
||||
rows: (30%, 30%, auto),
|
||||
[C],
|
||||
[C],
|
||||
table.footer[*A*][*B*],
|
||||
)
|
||||
|
||||
---
|
||||
#grid(
|
||||
grid.footer(grid.cell(y: 2)[b]),
|
||||
grid.cell(y: 0)[a],
|
||||
grid.cell(y: 1)[c],
|
||||
)
|
||||
|
||||
---
|
||||
// Ensure footer properly expands
|
||||
#grid(
|
||||
columns: 2,
|
||||
[a], [],
|
||||
[b], [],
|
||||
grid.cell(x: 1, y: 3, rowspan: 4)[b],
|
||||
grid.cell(y: 2, rowspan: 2)[a],
|
||||
grid.footer(),
|
||||
grid.cell(y: 4)[d],
|
||||
grid.cell(y: 5)[e],
|
||||
grid.cell(y: 6)[f],
|
||||
)
|
||||
|
||||
---
|
||||
// Error: 2:3-2:19 footer must end at the last row
|
||||
#grid(
|
||||
grid.footer([a]),
|
||||
[b],
|
||||
)
|
||||
|
||||
---
|
||||
// Error: 3:3-3:19 footer must end at the last row
|
||||
#grid(
|
||||
columns: 2,
|
||||
grid.footer([a]),
|
||||
[b],
|
||||
)
|
||||
|
||||
---
|
||||
// Error: 4:3-4:19 footer would conflict with a cell placed before it at column 1 row 0
|
||||
// Hint: 4:3-4:19 try reducing that cell's rowspan or moving the footer
|
||||
#grid(
|
||||
columns: 2,
|
||||
grid.header(),
|
||||
grid.footer([a]),
|
||||
grid.cell(x: 1, y: 0, rowspan: 2)[a],
|
||||
)
|
||||
|
||||
---
|
||||
// Error: 4:3-4:19 cannot have more than one footer
|
||||
#grid(
|
||||
[a],
|
||||
grid.footer([a]),
|
||||
grid.footer([b]),
|
||||
)
|
||||
|
||||
---
|
||||
// Error: 3:3-3:20 cannot use `table.footer` as a grid footer; use `grid.footer` instead
|
||||
#grid(
|
||||
[a],
|
||||
table.footer([a]),
|
||||
)
|
||||
|
||||
---
|
||||
// Error: 3:3-3:19 cannot use `grid.footer` as a table footer; use `table.footer` instead
|
||||
#table(
|
||||
[a],
|
||||
grid.footer([a]),
|
||||
)
|
||||
|
||||
---
|
||||
// Error: 14-28 cannot place a grid footer within another footer or header
|
||||
#grid.header(grid.footer[a])
|
||||
|
||||
---
|
||||
// Error: 14-29 cannot place a table footer within another footer or header
|
||||
#grid.header(table.footer[a])
|
||||
|
||||
---
|
||||
// Error: 15-29 cannot place a grid footer within another footer or header
|
||||
#table.header(grid.footer[a])
|
||||
|
||||
---
|
||||
// Error: 15-30 cannot place a table footer within another footer or header
|
||||
#table.header(table.footer[a])
|
||||
|
||||
---
|
||||
// Error: 14-28 cannot place a grid footer within another footer or header
|
||||
#grid.footer(grid.footer[a])
|
||||
|
||||
---
|
||||
// Error: 14-29 cannot place a table footer within another footer or header
|
||||
#grid.footer(table.footer[a])
|
||||
|
||||
---
|
||||
// Error: 15-29 cannot place a grid footer within another footer or header
|
||||
#table.footer(grid.footer[a])
|
||||
|
||||
---
|
||||
// Error: 15-30 cannot place a table footer within another footer or header
|
||||
#table.footer(table.footer[a])
|
||||
|
||||
---
|
||||
// Error: 14-28 cannot place a grid header within another header or footer
|
||||
#grid.footer(grid.header[a])
|
||||
|
||||
---
|
||||
// Error: 14-29 cannot place a table header within another header or footer
|
||||
#grid.footer(table.header[a])
|
||||
|
||||
---
|
||||
// Error: 15-29 cannot place a grid header within another header or footer
|
||||
#table.footer(grid.header[a])
|
||||
|
||||
---
|
||||
// Error: 15-30 cannot place a table header within another header or footer
|
||||
#table.footer(table.header[a])
|
31
tests/typ/layout/grid-footers-2.typ
Normal file
31
tests/typ/layout/grid-footers-2.typ
Normal file
@ -0,0 +1,31 @@
|
||||
#set page(height: 17em)
|
||||
#table(
|
||||
rows: (auto, 2.5em, auto),
|
||||
table.header[*Hello*][*World*],
|
||||
block(width: 2em, height: 10em, fill: red),
|
||||
table.footer[*Bye*][*World*],
|
||||
)
|
||||
|
||||
---
|
||||
// Rowspan sizing algorithm doesn't do the best job at non-contiguous content
|
||||
// ATM.
|
||||
#set page(height: 20em)
|
||||
|
||||
#table(
|
||||
rows: (auto, 2.5em, 2em, auto, 5em, 2em, 2.5em),
|
||||
table.header[*Hello*][*World*],
|
||||
table.cell(rowspan: 3, lorem(20)),
|
||||
table.footer[*Ok*][*Bye*],
|
||||
)
|
||||
|
||||
---
|
||||
// This should look right
|
||||
#set page(height: 20em)
|
||||
|
||||
#table(
|
||||
rows: (auto, 2.5em, 2em, auto),
|
||||
gutter: 3pt,
|
||||
table.header[*Hello*][*World*],
|
||||
table.cell(rowspan: 3, lorem(20)),
|
||||
table.footer[*Ok*][*Bye*],
|
||||
)
|
44
tests/typ/layout/grid-footers-3.typ
Normal file
44
tests/typ/layout/grid-footers-3.typ
Normal file
@ -0,0 +1,44 @@
|
||||
// Test lack of space for header + text.
|
||||
#set page(height: 9em + 2.5em + 1.5em)
|
||||
|
||||
#table(
|
||||
rows: (auto, 2.5em, auto, auto, 10em, 2.5em, auto),
|
||||
gutter: 3pt,
|
||||
table.header[*Hello*][*World*],
|
||||
table.cell(rowspan: 3, lorem(30)),
|
||||
table.footer[*Ok*][*Bye*],
|
||||
)
|
||||
|
||||
---
|
||||
// Orphan header prevention test
|
||||
#set page(height: 13em)
|
||||
#v(8em)
|
||||
#grid(
|
||||
columns: 3,
|
||||
gutter: 5pt,
|
||||
grid.header(
|
||||
[*Mui*], [*A*], grid.cell(rowspan: 2, fill: orange)[*B*],
|
||||
[*Header*], [*Header* #v(0.1em)],
|
||||
),
|
||||
..([Test], [Test], [Test]) * 7,
|
||||
grid.footer(
|
||||
[*Mui*], [*A*], grid.cell(rowspan: 2, fill: orange)[*B*],
|
||||
[*Footer*], [*Footer* #v(0.1em)],
|
||||
),
|
||||
)
|
||||
|
||||
---
|
||||
// Empty footer should just be a repeated blank row
|
||||
#set page(height: 8em)
|
||||
#table(
|
||||
columns: 4,
|
||||
align: center + horizon,
|
||||
table.header(),
|
||||
..range(0, 2).map(i => (
|
||||
[John \##i],
|
||||
table.cell(stroke: green)[123],
|
||||
table.cell(stroke: blue)[456],
|
||||
[789]
|
||||
)).flatten(),
|
||||
table.footer(),
|
||||
)
|
42
tests/typ/layout/grid-footers-4.typ
Normal file
42
tests/typ/layout/grid-footers-4.typ
Normal file
@ -0,0 +1,42 @@
|
||||
// When a footer has a rowspan with an empty row, it should be displayed
|
||||
// properly
|
||||
#set page(height: 14em, width: auto)
|
||||
|
||||
#let count = counter("g")
|
||||
#table(
|
||||
rows: (auto, 2em, auto, auto),
|
||||
table.header(
|
||||
[eeec],
|
||||
table.cell(rowspan: 2, count.step() + count.display()),
|
||||
),
|
||||
[d],
|
||||
block(width: 5em, fill: yellow, lorem(7)),
|
||||
[d],
|
||||
table.footer(
|
||||
[eeec],
|
||||
table.cell(rowspan: 2, count.step() + count.display()),
|
||||
)
|
||||
)
|
||||
#count.display()
|
||||
|
||||
---
|
||||
// Nested table with footer should repeat both footers
|
||||
#set page(height: 10em, width: auto)
|
||||
#table(
|
||||
table(
|
||||
[a\ b\ c\ d],
|
||||
table.footer[b],
|
||||
),
|
||||
table.footer[a],
|
||||
)
|
||||
|
||||
---
|
||||
#set page(height: 12em, width: auto)
|
||||
#table(
|
||||
[a\ b\ c\ d],
|
||||
table.footer(table(
|
||||
[c],
|
||||
[d],
|
||||
table.footer[b],
|
||||
))
|
||||
)
|
28
tests/typ/layout/grid-footers-5.typ
Normal file
28
tests/typ/layout/grid-footers-5.typ
Normal file
@ -0,0 +1,28 @@
|
||||
// General footer-only tests
|
||||
#set page(height: 9em)
|
||||
#table(
|
||||
columns: 2,
|
||||
[a], [],
|
||||
[b], [],
|
||||
[c], [],
|
||||
[d], [],
|
||||
[e], [],
|
||||
table.footer(
|
||||
[*Ok*], table.cell(rowspan: 2)[test],
|
||||
[*Thanks*]
|
||||
)
|
||||
)
|
||||
|
||||
---
|
||||
#set page(height: 5em)
|
||||
#table(
|
||||
table.footer[a][b][c]
|
||||
)
|
||||
|
||||
---
|
||||
#table(table.footer[a][b][c])
|
||||
|
||||
#table(
|
||||
gutter: 3pt,
|
||||
table.footer[a][b][c]
|
||||
)
|
@ -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])
|
||||
|
Loading…
x
Reference in New Issue
Block a user