Compare commits

...

31 Commits

Author SHA1 Message Date
PgBiel
98e23c1370
Merge d13617ed9b9dd95376b8d068b85513cba8b1b702 into 36ecbb2c8dccc1a31c43fee1466f1425844d8607 2025-07-07 16:34:17 +02:00
Robin
36ecbb2c8d
Refer to json function instead of deprecated json.decode in groups docs (#6552) 2025-07-07 14:15:32 +00:00
Robin
51ab5b815c
Fix minor typo in function docs (#6542) 2025-07-07 14:15:10 +00:00
PgBiel
d13617ed9b skip layout of redundant gutter at the top of footer 2025-06-28 22:39:35 -03:00
PgBiel
315612b1f7 detect short lived headers and footers at the table edges
even if headers and footers are interleaved
2025-06-28 22:39:35 -03:00
PgBiel
f3cc3bdae7 fix space calculation of new footers
however, there are widows...
2025-06-28 22:39:35 -03:00
PgBiel
a2f5593174 improve check to pull next repeating footer 2025-06-28 22:39:35 -03:00
PgBiel
c346fb8589 initial proper subfooter unit tests 2025-06-28 22:39:35 -03:00
PgBiel
8f434146d8 clippy lints 2025-06-28 22:39:35 -03:00
PgBiel
40ae2324d1 test subfooters demo 2025-06-28 22:39:35 -03:00
PgBiel
858e620ef7 fix footer layout order and consecutive footer pushing 2025-06-28 22:39:35 -03:00
PgBiel
8c416b88f2 add footer level fields 2025-06-28 22:39:35 -03:00
PgBiel
eae79440b0 update multiple footer tests 2025-06-28 22:39:35 -03:00
PgBiel
7ee5dfaa89 fix footer layout range 2025-06-28 22:39:35 -03:00
PgBiel
183f47ecc0 use footer.range like header.range 2025-06-28 22:39:35 -03:00
PgBiel
bd7e403a6d fix last repeating footers not being pushed 2025-06-28 22:39:35 -03:00
PgBiel
b3fd4676c4 not using repeatable 2025-06-28 22:39:35 -03:00
PgBiel
0951fe13fd resolve multiple footers 2025-06-28 22:39:35 -03:00
PgBiel
f9b1bfd1b0 fix tfoot in table html 2025-06-28 22:39:35 -03:00
PgBiel
b26e004be9 fix footer widow check and rowspans 2025-06-28 22:39:35 -03:00
PgBiel
9422ecc74a fix footer progression 2025-06-28 22:39:35 -03:00
PgBiel
58db042ff3 support repeated footers in rowspan simulation 2025-06-28 22:39:35 -03:00
PgBiel
e89e3066a4 repeated method fixes 2025-06-28 22:39:35 -03:00
PgBiel
3de1237f54 temporary workaround for footer lines 2025-06-28 22:39:35 -03:00
PgBiel
b63f6c99df initial footer simulation and placement 2025-06-28 22:39:35 -03:00
PgBiel
db2ac385a9 move height resets to finish region internal 2025-06-28 22:39:35 -03:00
PgBiel
5f663a8da4 initial footer properties and bumping 2025-06-28 22:39:35 -03:00
PgBiel
3bf0f2b48c clone footers after sorting
might want to rethink this
2025-06-28 22:39:35 -03:00
PgBiel
0a27b50551 footer pre sorting 2025-06-28 22:39:35 -03:00
PgBiel
5292c5b198 update html code for multiple footers
todo: test
2025-06-28 22:39:35 -03:00
PgBiel
cce5fe739a multiple footers 2025-06-28 22:39:35 -03:00
31 changed files with 807 additions and 314 deletions

View File

@ -6,6 +6,7 @@ use typst_library::foundations::{Resolve, StyleChain};
use typst_library::layout::grid::resolve::{ use typst_library::layout::grid::resolve::{
Cell, CellGrid, Header, LinePosition, Repeatable, Cell, CellGrid, Header, LinePosition, Repeatable,
}; };
use typst_library::layout::resolve::Footer;
use typst_library::layout::{ use typst_library::layout::{
Abs, Axes, Dir, Fr, Fragment, Frame, FrameItem, Length, Point, Region, Regions, Rel, Abs, Axes, Dir, Fr, Fragment, Frame, FrameItem, Length, Point, Region, Regions, Rel,
Size, Sizing, Size, Sizing,
@ -60,6 +61,16 @@ pub struct GridLayouter<'a> {
pub(super) pending_headers: &'a [Repeatable<Header>], pub(super) pending_headers: &'a [Repeatable<Header>],
/// Next headers to be processed. /// Next headers to be processed.
pub(super) upcoming_headers: &'a [Repeatable<Header>], pub(super) upcoming_headers: &'a [Repeatable<Header>],
/// Currently repeating footers, one per level. Sorted by increasing
/// levels.
///
/// Note that some levels may be absent, in particular level 0, which does
/// not exist (so all levels are >= 1).
pub(super) repeating_footers: Vec<&'a Footer>,
/// Next footers to be processed.
pub(super) upcoming_footers: &'a [Repeatable<Footer>],
/// Next footers sorted by when they start repeating.
pub(super) upcoming_sorted_footers: &'a [Repeatable<Footer>],
/// State of the row being currently laid out. /// State of the row being currently laid out.
/// ///
/// This is kept as a field to avoid passing down too many parameters from /// This is kept as a field to avoid passing down too many parameters from
@ -155,6 +166,12 @@ pub(super) struct Current {
/// when finding a new header and causing existing repeating headers to /// when finding a new header and causing existing repeating headers to
/// stop. /// stop.
pub(super) repeating_header_heights: Vec<Abs>, pub(super) repeating_header_heights: Vec<Abs>,
/// The height for each repeating footer that will be placed in this region.
///
/// This is used to know how much to update `repeating_footer_height` by
/// when finding a footer and causing existing repeating footers to
/// stop (and new ones to start).
pub(super) repeating_footer_heights: Vec<Abs>,
/// The simulated footer height for this region. /// The simulated footer height for this region.
/// ///
/// The simulation occurs before any rows are laid out for a region. /// The simulation occurs before any rows are laid out for a region.
@ -215,7 +232,7 @@ pub(super) enum Row {
impl Row { impl Row {
/// Returns the `y` index of this row. /// Returns the `y` index of this row.
fn index(&self) -> usize { pub(super) fn index(&self) -> usize {
match self { match self {
Self::Frame(_, y, _) => *y, Self::Frame(_, y, _) => *y,
Self::Fr(_, y, _) => *y, Self::Fr(_, y, _) => *y,
@ -253,6 +270,10 @@ impl<'a> GridLayouter<'a> {
repeating_headers: vec![], repeating_headers: vec![],
upcoming_headers: &grid.headers, upcoming_headers: &grid.headers,
pending_headers: Default::default(), pending_headers: Default::default(),
// This is updated on layout
repeating_footers: vec![],
upcoming_footers: &grid.footers,
upcoming_sorted_footers: &grid.sorted_footers,
row_state: RowState::default(), row_state: RowState::default(),
current: Current { current: Current {
initial: regions.size, initial: regions.size,
@ -264,6 +285,7 @@ impl<'a> GridLayouter<'a> {
lrows_orphan_snapshot: None, lrows_orphan_snapshot: None,
repeating_header_height: Abs::zero(), repeating_header_height: Abs::zero(),
repeating_header_heights: vec![], repeating_header_heights: vec![],
repeating_footer_heights: vec![],
footer_height: Abs::zero(), footer_height: Abs::zero(),
}, },
span, span,
@ -274,15 +296,7 @@ impl<'a> GridLayouter<'a> {
pub fn layout(mut self, engine: &mut Engine) -> SourceResult<Fragment> { pub fn layout(mut self, engine: &mut Engine) -> SourceResult<Fragment> {
self.measure_columns(engine)?; self.measure_columns(engine)?;
if let Some(footer) = &self.grid.footer { self.prepare_next_repeating_footers(true, engine)?;
if footer.repeated {
// Ensure rows in the first region will be aware of the
// possible presence of the footer.
self.prepare_footer(footer, engine, 0)?;
self.regions.size.y -= self.current.footer_height;
self.current.initial_after_repeats = self.regions.size.y;
}
}
let mut y = 0; let mut y = 0;
let mut consecutive_header_count = 0; let mut consecutive_header_count = 0;
@ -298,13 +312,15 @@ impl<'a> GridLayouter<'a> {
} }
} }
if let Some(footer) = &self.grid.footer { if let [next_footer, other_footers @ ..] = self.upcoming_footers {
if footer.repeated && y >= footer.start { // TODO(subfooters): effective range (consider gutter before
if y == footer.start { // if it was removed)
self.layout_footer(footer, engine, self.finished.len())?; if next_footer.range.contains(&y) {
self.flush_orphans(); self.upcoming_footers = other_footers;
} self.place_new_footer(engine, next_footer)?;
y = footer.end; self.flush_orphans();
y = next_footer.range.end;
continue; continue;
} }
} }
@ -1566,26 +1582,34 @@ impl<'a> GridLayouter<'a> {
// TODO(subfooters): explicitly check for short-lived footers. // TODO(subfooters): explicitly check for short-lived footers.
// TODO(subfooters): widow prevention for non-repeated footers with a // TODO(subfooters): widow prevention for non-repeated footers with a
// similar mechanism / when implementing multiple footers. // similar mechanism / when implementing multiple footers.
let footer_would_be_widow = matches!(&self.grid.footer, Some(footer) if footer.repeated) // TODO(subfooters): could progress check must be replaced to consider
&& self.current.lrows.is_empty() // the presence of non-repeating footer (then always true).
&& self.current.could_progress_at_top; let may_place_footers = !self.repeating_footers.is_empty()
&& (!self.current.lrows.is_empty() || !self.current.could_progress_at_top);
let mut laid_out_footer_start = None; if may_place_footers {
if !footer_would_be_widow { // Don't layout the footer if it would be alone with the header
if let Some(footer) = &self.grid.footer { // in the page (hence the widow check), and don't layout it
// Don't layout the footer if it would be alone with the header // twice (it is removed from repeating_footers once it is
// in the page (hence the widow check), and don't layout it // reached).
// twice (check below). //
// // Use index for iteration to avoid borrow conflict.
// TODO(subfooters): this check can be replaced by a vector of //
// repeating footers in the future, and/or some "pending // Note that repeating footers are in reverse order.
// footers" vector for footers we're about to place. //
if footer.repeated // TODO(subfooters): "pending footers" vector for footers we're
&& self.current.lrows.iter().all(|row| row.index() < footer.start) // about to place. Needed for widow prevention of non-repeated
{ // footers.
laid_out_footer_start = Some(footer.start); let mut i = 0;
self.layout_footer(footer, engine, self.finished.len())?; while let Some(footer_index) = self.repeating_footers.len().checked_sub(1 + i)
} {
self.layout_footer(
self.repeating_footers[footer_index],
false,
engine,
self.finished.len(),
)?;
i += 1;
} }
} }
@ -1684,12 +1708,24 @@ impl<'a> GridLayouter<'a> {
// laid out at the first frame of the row). // laid out at the first frame of the row).
// Any rowspans ending before this row are laid out even // Any rowspans ending before this row are laid out even
// on this row's first frame. // on this row's first frame.
if laid_out_footer_start.is_none_or(|footer_start| { if (!may_place_footers
// If this is a footer row, then only lay out this rowspan || self.repeating_footers.iter().all(|footer| {
// if the rowspan is contained within the footer. // If this is a footer row, then only lay out this rowspan
y < footer_start || rowspan.y >= footer_start // if the rowspan is contained within the footer.
}) && (rowspan.y + rowspan.rowspan < y + 1 // Since the footer is a row from "the future", it
|| rowspan.y + rowspan.rowspan == y + 1 && is_last) // always has a larger Y than all active rowspans,
// so we must not interpret a rowspan before it to have
// already ended because we saw a repeated footer.
//
// Of course, not a concern for non-repeated or
// short-lived footers as they only appear once.
//
// TODO(subfooters): use effective range
// (what about the gutter?).
!footer.range.contains(&y) || footer.range.contains(&rowspan.y)
}))
&& (rowspan.y + rowspan.rowspan < y + 1
|| rowspan.y + rowspan.rowspan == y + 1 && is_last)
{ {
// Rowspan ends at this or an earlier row, so we take // Rowspan ends at this or an earlier row, so we take
// it from the rowspans vector and lay it out. // it from the rowspans vector and lay it out.
@ -1732,25 +1768,18 @@ impl<'a> GridLayouter<'a> {
); );
if !last { if !last {
self.current.repeated_header_rows = 0;
self.current.last_repeated_header_end = 0;
self.current.repeating_header_height = Abs::zero();
self.current.repeating_header_heights.clear();
let disambiguator = self.finished.len(); let disambiguator = self.finished.len();
if let Some(footer) = if !self.repeating_footers.is_empty() {
self.grid.footer.as_ref().and_then(Repeatable::as_repeated) // TODO(subfooters): let's not...
{ let footers = self.repeating_footers.clone();
self.prepare_footer(footer, engine, disambiguator)?; self.prepare_repeating_footers(
footers.iter().copied(),
true,
engine,
disambiguator,
)?;
} }
// Ensure rows don't try to overrun the footer.
// Note that header layout will only subtract this again if it has
// to skip regions to fit headers, so there is no risk of
// subtracting this twice.
self.regions.size.y -= self.current.footer_height;
self.current.initial_after_repeats = self.regions.size.y;
if !self.repeating_headers.is_empty() || !self.pending_headers.is_empty() { if !self.repeating_headers.is_empty() || !self.pending_headers.is_empty() {
// Add headers to the new region. // Add headers to the new region.
self.layout_active_headers(engine)?; self.layout_active_headers(engine)?;
@ -1780,6 +1809,13 @@ impl<'a> GridLayouter<'a> {
self.current.could_progress_at_top = self.regions.may_progress(); self.current.could_progress_at_top = self.regions.may_progress();
self.current.repeated_header_rows = 0;
self.current.last_repeated_header_end = 0;
self.current.repeating_header_height = Abs::zero();
self.current.repeating_header_heights.clear();
self.current.footer_height = Abs::zero();
self.current.repeating_footer_heights.clear();
if !self.grid.headers.is_empty() { if !self.grid.headers.is_empty() {
self.finished_header_rows.push(header_row_info); self.finished_header_rows.push(header_row_info);
} }

View File

@ -512,15 +512,18 @@ pub fn hline_stroke_at_column(
); );
// Prioritize the footer's top stroke as well where applicable. // Prioritize the footer's top stroke as well where applicable.
// TODO(subfooters): do this properly (store footer rows)
let bottom_stroke_comes_from_footer = grid let bottom_stroke_comes_from_footer = grid
.footer .footers
.as_ref() .last()
.and_then(Repeatable::as_repeated) .and_then(Repeatable::as_repeated)
.is_some_and(|footer| { .is_some_and(|footer| {
// Ensure the row below us is a repeated footer. // Ensure the row below us is a repeated footer.
// FIXME: Make this check more robust when footers at arbitrary // FIXME: Make this check more robust when footers at arbitrary
// positions are added. // positions are added.
local_top_y.unwrap_or(0) + 1 < footer.start && y >= footer.start footer.range.end == grid.rows.len()
&& local_top_y.unwrap_or(0) + 1 < footer.range.start
&& y >= footer.range.start
}); });
let (prioritized_cell_stroke, deprioritized_cell_stroke) = let (prioritized_cell_stroke, deprioritized_cell_stroke) =
@ -638,7 +641,7 @@ mod test {
vec![], vec![],
vec![], vec![],
vec![], vec![],
None, vec![],
entries, entries,
) )
} }
@ -1176,7 +1179,7 @@ mod test {
vec![], vec![],
vec![], vec![],
vec![], vec![],
None, vec![],
entries, entries,
) )
} }

View File

@ -240,16 +240,18 @@ impl<'a> GridLayouter<'a> {
self.current.initial_after_repeats = self.regions.size.y; self.current.initial_after_repeats = self.regions.size.y;
} }
if let Some(footer) = &self.grid.footer { if !self.repeating_footers.is_empty() && skipped_region {
if footer.repeated && skipped_region { // Simulate the footer again; the region's 'full' might have
// Simulate the footer again; the region's 'full' might have // changed.
// changed. let (footer_height, footer_heights) = self.simulate_footer_heights(
self.regions.size.y += self.current.footer_height; self.repeating_footers.iter().copied(),
self.current.footer_height = self &self.regions,
.simulate_footer(footer, &self.regions, engine, disambiguator)? engine,
.height; disambiguator,
self.regions.size.y -= self.current.footer_height; )?;
}
self.current.footer_height = footer_height;
self.current.repeating_footer_heights.extend(footer_heights);
} }
let repeating_header_rows = let repeating_header_rows =
@ -463,74 +465,243 @@ impl<'a> GridLayouter<'a> {
) )
} }
/// Updates `self.footer_height` by simulating the footer, and skips to fitting region. /// Place a footer we have reached through normal row layout.
pub fn prepare_footer( pub fn place_new_footer(
&mut self, &mut self,
footer: &Footer, engine: &mut Engine,
footer: &Repeatable<Footer>,
) -> SourceResult<()> {
// TODO(subfooters): short-lived check
if !footer.repeated {
// TODO(subfooters): widow prevention for this.
// Will need some lookahead. For now, act as short-lived.
let footer_height =
self.simulate_footer(footer, &self.regions, engine, 0)?.height;
// Skip to fitting region where only this footer fits.
while self.unbreakable_rows_left == 0
&& !self.regions.size.y.fits(footer_height)
&& self.may_progress_with_repeats()
{
// Advance regions until we can place the footer.
// Treat as a normal row group.
self.finish_region(engine, false)?;
}
self.layout_footer(footer, true, engine, 0)?;
} else {
// Placing a non-short-lived repeating footer, so it must be
// the latest one in the repeating footers vector.
let latest_repeating_footer = self.repeating_footers.pop().unwrap();
assert_eq!(latest_repeating_footer.range.start, footer.range.start);
let expected_footer_height =
self.current.repeating_footer_heights.pop().unwrap();
// Ensure upcoming rows won't see that this footer will occupy
// any space in future regions anymore.
self.current.footer_height -= expected_footer_height;
// Ensure footer rows have their own expected height
// available. While not that relevant for them, as they will be
// laid out as an unbreakable row group, it's relevant for any
// further rows in the same region.
self.regions.size.y += expected_footer_height;
self.layout_footer(footer, false, engine, self.finished.len())?;
}
// If the next group of footers would conflict with other repeating
// footers, wait for them to finish repeating before adding more to
// repeat.
if self.repeating_footers.is_empty()
|| self
.upcoming_sorted_footers
.first()
.is_some_and(|f| f.level >= footer.level)
{
self.prepare_next_repeating_footers(false, engine)?;
}
Ok(())
}
/// Takes all non-conflicting consecutive footers which are about to start
/// repeating, skips to the first region where they all fit, and pushes
/// them to `repeating_footers`, sorted by ascending levels.
pub fn prepare_next_repeating_footers(
&mut self,
first_footers: bool,
engine: &mut Engine,
) -> SourceResult<()> {
let [next_footer, other_footers @ ..] = self.upcoming_sorted_footers else {
// No footers to take.
return Ok(());
};
// TODO(subfooters): also ignore short-lived footers.
if !next_footer.repeated {
// Skip this footer and don't do anything until we get to it.
//
// TODO(subfooters): grouping and laying out non-repeated with
// repeated, with widow prevention.
self.upcoming_sorted_footers = other_footers;
return Ok(());
}
// Collect upcoming consecutive footers, they will start repeating with
// this one if compatible
let mut min_level = next_footer.level;
let first_conflicting_index = other_footers
.iter()
.take_while(|f| {
// TODO(subfooters): check for short-lived
let compatible = f.repeated && f.level > min_level;
min_level = f.level;
compatible
})
.count()
+ 1;
let (next_repeating_footers, new_upcoming_footers) =
self.upcoming_sorted_footers.split_at(first_conflicting_index);
self.upcoming_sorted_footers = new_upcoming_footers;
self.prepare_repeating_footers(
next_repeating_footers.iter().map(Repeatable::deref),
first_footers,
engine,
0,
)?;
self.repeating_footers
.extend(next_repeating_footers.iter().filter_map(Repeatable::as_repeated));
Ok(())
}
/// Updates `self.current.repeating_footer_height` by simulating repeating
/// footers, and skips to fitting region.
pub fn prepare_repeating_footers(
&mut self,
footers: impl ExactSizeIterator<Item = &'a Footer> + Clone,
at_region_top: bool,
engine: &mut Engine, engine: &mut Engine,
disambiguator: usize, disambiguator: usize,
) -> SourceResult<()> { ) -> SourceResult<()> {
let footer_height = self let (mut expected_footer_height, mut expected_footer_heights) = self
.simulate_footer(footer, &self.regions, engine, disambiguator)? .simulate_footer_heights(
.height; footers.clone(),
&self.regions,
engine,
disambiguator,
)?;
// Skip to fitting region where all of them fit at once.
//
// Can't be widows: they are assumed to not be short-lived, so
// there is at least one non-footer before them, and this
// function is called right after placing a new footer, but
// before the next non-footer, or at the top of the region,
// at which point we haven't reached the row before the highest
// level footer yet since the footer itself won't cause a
// region break.
let mut skipped_region = false; let mut skipped_region = false;
while self.unbreakable_rows_left == 0 while self.unbreakable_rows_left == 0
&& !self.regions.size.y.fits(footer_height) && !self.regions.size.y.fits(expected_footer_height)
&& self.regions.may_progress() && self.regions.may_progress()
{ {
// Advance regions without any output until we can place the // Advance regions without any output until we can place the
// footer. // footer.
self.finish_region_internal( if at_region_top {
Frame::soft(Axes::splat(Abs::zero())), self.finish_region_internal(
vec![], Frame::soft(Axes::splat(Abs::zero())),
Default::default(), vec![],
); Default::default(),
);
} else {
self.finish_region(engine, false)?;
}
skipped_region = true; skipped_region = true;
} }
// TODO(subfooters): Consider resetting header height etc. if we skip if skipped_region {
// region. (Maybe move that step to `finish_region_internal`.)
//
// That is unnecessary at the moment as 'prepare_footers' is only
// called at the start of the region, so header height is always zero
// and no headers were placed so far, but what about when we can have
// footers in the middle of the region? Let's think about this then.
self.current.footer_height = if skipped_region {
// Simulate the footer again; the region's 'full' might have // Simulate the footer again; the region's 'full' might have
// changed. // changed, and the vector of heights was cleared.
self.simulate_footer(footer, &self.regions, engine, disambiguator)? (expected_footer_height, expected_footer_heights) = self
.height .simulate_footer_heights(footers, &self.regions, engine, disambiguator)?;
} else { }
footer_height
}; // Ensure rows don't try to overrun the new footers.
// Note that header layout will only subtract this again if it has
// to skip regions to fit headers, so there is no risk of
// subtracting this twice.
self.regions.size.y -= expected_footer_height;
self.current.footer_height += expected_footer_height;
self.current.repeating_footer_heights.extend(expected_footer_heights);
if at_region_top {
self.current.initial_after_repeats = self.regions.size.y;
}
Ok(()) Ok(())
} }
pub fn simulate_footer_heights(
&self,
footers: impl ExactSizeIterator<Item = &'a Footer>,
regions: &Regions<'_>,
engine: &mut Engine,
disambiguator: usize,
) -> SourceResult<(Abs, Vec<Abs>)> {
let mut total_footer_height = Abs::zero();
let mut footer_heights = Vec::with_capacity(footers.len());
for footer in footers {
let footer_height =
self.simulate_footer(footer, regions, engine, disambiguator)?.height;
total_footer_height += footer_height;
footer_heights.push(footer_height);
}
Ok((total_footer_height, footer_heights))
}
/// Lays out all rows in the footer. /// Lays out all rows in the footer.
/// They are unbreakable. /// They are unbreakable.
pub fn layout_footer( pub fn layout_footer(
&mut self, &mut self,
footer: &Footer, footer: &Footer,
as_short_lived: bool,
engine: &mut Engine, engine: &mut Engine,
disambiguator: usize, disambiguator: usize,
) -> SourceResult<()> { ) -> SourceResult<()> {
// Ensure footer rows have their own height available. let footer_len = footer.range.end - footer.range.start;
// Won't change much as we're creating an unbreakable row group
// anyway, so this is mostly for correctness.
self.regions.size.y += self.current.footer_height;
let repeats = self.grid.footer.as_ref().is_some_and(|f| f.repeated);
let footer_len = self.grid.rows.len() - footer.start;
self.unbreakable_rows_left += footer_len; self.unbreakable_rows_left += footer_len;
for y in footer.start..self.grid.rows.len() { let footer_start = if self.grid.is_gutter_track(footer.range.start)
&& self
.current
.lrows
.last()
.is_none_or(|r| self.grid.is_gutter_track(r.index()))
{
// Skip gutter at the top of footer if there's already a gutter
// from a repeated header right before it in the current region.
// Normally, that shouldn't happen as it indicates we have a widow,
// but we can't fully prevent widows anyway.
footer.range.start + 1
} else {
footer.range.start
};
for y in footer_start..footer.range.end {
self.layout_row_with_state( self.layout_row_with_state(
y, y,
engine, engine,
disambiguator, disambiguator,
RowState { RowState {
in_active_repeatable: repeats, in_active_repeatable: !as_short_lived,
..Default::default() ..Default::default()
}, },
)?; )?;
@ -553,8 +724,8 @@ impl<'a> GridLayouter<'a> {
// assume that the amount of unbreakable rows following the first row // assume that the amount of unbreakable rows following the first row
// in the footer will be precisely the rows in the footer. // in the footer will be precisely the rows in the footer.
self.simulate_unbreakable_row_group( self.simulate_unbreakable_row_group(
footer.start, footer.range.start,
Some(footer.end - footer.start), Some(footer.range.end - footer.range.start),
regions, regions,
engine, engine,
disambiguator, disambiguator,

View File

@ -234,24 +234,12 @@ impl GridLayouter<'_> {
engine: &mut Engine, engine: &mut Engine,
) -> SourceResult<()> { ) -> SourceResult<()> {
if self.unbreakable_rows_left == 0 { if self.unbreakable_rows_left == 0 {
// By default, the amount of unbreakable rows starting at the
// current row is dynamic and depends on the amount of upcoming
// unbreakable cells (with or without a rowspan setting).
let mut amount_unbreakable_rows = None;
if let Some(footer) = &self.grid.footer {
if !footer.repeated && current_row >= footer.start {
// Non-repeated footer, so keep it unbreakable.
//
// TODO(subfooters): This will become unnecessary
// once non-repeated footers are treated differently and
// have widow prevention.
amount_unbreakable_rows = Some(self.grid.rows.len() - footer.start);
}
}
let row_group = self.simulate_unbreakable_row_group( let row_group = self.simulate_unbreakable_row_group(
current_row, current_row,
amount_unbreakable_rows, // 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).
None,
&self.regions, &self.regions,
engine, engine,
0, 0,
@ -400,7 +388,8 @@ impl GridLayouter<'_> {
if breakable if breakable
&& (!self.repeating_headers.is_empty() && (!self.repeating_headers.is_empty()
|| !self.pending_headers.is_empty() || !self.pending_headers.is_empty()
|| matches!(&self.grid.footer, Some(footer) if footer.repeated)) // TODO(subfooters): pending footers
|| !self.repeating_footers.is_empty())
{ {
// Subtract header and footer height from all upcoming regions // Subtract header and footer height from all upcoming regions
// when measuring the cell, including the last repeated region. // when measuring the cell, including the last repeated region.
@ -1176,14 +1165,23 @@ impl<'a> RowspanSimulator<'a> {
(None, Abs::zero()) (None, Abs::zero())
}; };
let footer_height = if let Some(footer) = let (repeating_footers, footer_height) = if layouter.repeating_footers.is_empty()
layouter.grid.footer.as_ref().and_then(Repeatable::as_repeated)
{ {
layouter (None, Abs::zero())
.simulate_footer(footer, &self.regions, engine, disambiguator)?
.height
} else { } else {
Abs::zero() // Only repeating footers have survived after the first region
// break.
// TODO(subfooters): consider pending footers
let repeating_footers = layouter.repeating_footers.iter().copied();
let (footer_height, _) = layouter.simulate_footer_heights(
repeating_footers.clone(),
&self.regions,
engine,
disambiguator,
)?;
(Some(repeating_footers), footer_height)
}; };
let mut skipped_region = false; let mut skipped_region = false;
@ -1212,15 +1210,18 @@ impl<'a> RowspanSimulator<'a> {
}; };
} }
if let Some(footer) = if let Some(repeating_footers) = repeating_footers {
layouter.grid.footer.as_ref().and_then(Repeatable::as_repeated)
{
self.footer_height = if skipped_region { self.footer_height = if skipped_region {
// Simulate footers again, at the new region, as // Simulate footers again, at the new region, as
// the full region height may change. // the full region height may change.
layouter layouter
.simulate_footer(footer, &self.regions, engine, disambiguator)? .simulate_footer_heights(
.height repeating_footers,
&self.regions,
engine,
disambiguator,
)?
.0
} else { } else {
footer_height footer_height
}; };

View File

@ -20,7 +20,7 @@ use crate::foundations::{
/// ///
/// You can call a function by writing a comma-separated list of function /// You can call a function by writing a comma-separated list of function
/// _arguments_ enclosed in parentheses directly after the function name. /// _arguments_ enclosed in parentheses directly after the function name.
/// Additionally, you can pass any number of trailing content blocks arguments /// Additionally, you can pass any number of trailing content block arguments
/// to a function _after_ the normal argument list. If the normal argument list /// to a function _after_ the normal argument list. If the normal argument list
/// would become empty, it can be omitted. Typst supports positional and named /// would become empty, it can be omitted. Typst supports positional and named
/// arguments. The former are identified by position and type, while the latter /// arguments. The former are identified by position and type, while the latter

View File

@ -496,6 +496,16 @@ pub struct GridFooter {
#[default(true)] #[default(true)]
pub repeat: bool, pub repeat: bool,
/// The level of the footer. Must not be zero.
///
/// This allows repeating multiple footers at once. Footers with different
/// levels can repeat together, as long as they have descending levels.
///
/// Notably, when a footer with a lower level stops repeating, all higher
/// or equal level headers start repeating, replacing the previous footer.
#[default(NonZeroU32::ONE)]
pub level: NonZeroU32,
/// The cells and lines within the footer. /// The cells and lines within the footer.
#[variadic] #[variadic]
pub children: Vec<GridItem>, pub children: Vec<GridItem>,

View File

@ -54,6 +54,7 @@ pub fn grid_to_cellgrid<'a>(
}, },
GridChild::Footer(footer) => ResolvableGridChild::Footer { GridChild::Footer(footer) => ResolvableGridChild::Footer {
repeat: footer.repeat(styles), repeat: footer.repeat(styles),
level: footer.level(styles),
span: footer.span(), span: footer.span(),
items: footer.children.iter().map(resolve_item), items: footer.children.iter().map(resolve_item),
}, },
@ -108,6 +109,7 @@ pub fn table_to_cellgrid<'a>(
}, },
TableChild::Footer(footer) => ResolvableGridChild::Footer { TableChild::Footer(footer) => ResolvableGridChild::Footer {
repeat: footer.repeat(styles), repeat: footer.repeat(styles),
level: footer.level(styles),
span: footer.span(), span: footer.span(),
items: footer.children.iter().map(resolve_item), items: footer.children.iter().map(resolve_item),
}, },
@ -445,31 +447,22 @@ pub struct Header {
} }
/// A repeatable grid footer. Stops at the last row. /// A repeatable grid footer. Stops at the last row.
#[derive(Debug)] #[derive(Debug, Clone)]
pub struct Footer { pub struct Footer {
/// The first row included in this footer. /// The range of rows included in this footer.
pub start: usize, pub range: Range<usize>,
/// The index after the last row included in this footer.
pub end: usize,
/// The footer's level. /// The footer's level.
/// ///
/// Used similarly to header level. /// Used similarly to header level.
pub level: u32, pub level: u32,
} }
impl Footer {
/// The footer's range of included rows.
#[inline]
pub fn range(&self) -> Range<usize> {
self.start..self.end
}
}
/// A possibly repeatable grid child (header or footer). /// A possibly repeatable grid child (header or footer).
/// ///
/// It still exists even when not repeatable, but must not have additional /// It still exists even when not repeatable, but must not have additional
/// considerations by grid layout, other than for consistency (such as making /// considerations by grid layout, other than for consistency (such as making
/// a certain group of rows unbreakable). /// a certain group of rows unbreakable).
#[derive(Debug, Clone)]
pub struct Repeatable<T> { pub struct Repeatable<T> {
inner: T, inner: T,
@ -656,7 +649,7 @@ impl<'a> Entry<'a> {
/// Any grid child, which can be either a header or an item. /// Any grid child, which can be either a header or an item.
pub enum ResolvableGridChild<T: ResolvableCell, I> { pub enum ResolvableGridChild<T: ResolvableCell, I> {
Header { repeat: bool, level: NonZeroU32, span: Span, items: I }, Header { repeat: bool, level: NonZeroU32, span: Span, items: I },
Footer { repeat: bool, span: Span, items: I }, Footer { repeat: bool, level: NonZeroU32, span: Span, items: I },
Item(ResolvableGridItem<T>), Item(ResolvableGridItem<T>),
} }
@ -678,8 +671,12 @@ pub struct CellGrid<'a> {
pub hlines: Vec<Vec<Line>>, pub hlines: Vec<Vec<Line>>,
/// The repeatable headers of this grid. /// The repeatable headers of this grid.
pub headers: Vec<Repeatable<Header>>, pub headers: Vec<Repeatable<Header>>,
/// The repeatable footer of this grid. /// The repeatable footers of this grid.
pub footer: Option<Repeatable<Footer>>, pub footers: Vec<Repeatable<Footer>>,
/// Footers sorted by order of when they start repeating, or should
/// otherwise be laid out for the first time (even if only once, for
/// non-repeating footers).
pub sorted_footers: Vec<Repeatable<Footer>>,
/// Whether this grid has gutters. /// Whether this grid has gutters.
pub has_gutter: bool, pub has_gutter: bool,
} }
@ -692,7 +689,7 @@ impl<'a> CellGrid<'a> {
cells: impl IntoIterator<Item = Cell<'a>>, cells: impl IntoIterator<Item = Cell<'a>>,
) -> Self { ) -> Self {
let entries = cells.into_iter().map(Entry::Cell).collect(); let entries = cells.into_iter().map(Entry::Cell).collect();
Self::new_internal(tracks, gutter, vec![], vec![], vec![], None, entries) Self::new_internal(tracks, gutter, vec![], vec![], vec![], vec![], entries)
} }
/// Generates the cell grid, given the tracks and resolved entries. /// Generates the cell grid, given the tracks and resolved entries.
@ -702,7 +699,7 @@ impl<'a> CellGrid<'a> {
vlines: Vec<Vec<Line>>, vlines: Vec<Vec<Line>>,
hlines: Vec<Vec<Line>>, hlines: Vec<Vec<Line>>,
headers: Vec<Repeatable<Header>>, headers: Vec<Repeatable<Header>>,
footer: Option<Repeatable<Footer>>, footers: Vec<Repeatable<Footer>>,
entries: Vec<Entry<'a>>, entries: Vec<Entry<'a>>,
) -> Self { ) -> Self {
let mut cols = vec![]; let mut cols = vec![];
@ -749,6 +746,8 @@ impl<'a> CellGrid<'a> {
rows.pop(); rows.pop();
} }
let sorted_footers = simulate_footer_repetition(&footers);
Self { Self {
cols, cols,
rows, rows,
@ -756,7 +755,8 @@ impl<'a> CellGrid<'a> {
vlines, vlines,
hlines, hlines,
headers, headers,
footer, footers,
sorted_footers,
has_gutter, has_gutter,
} }
} }
@ -895,6 +895,11 @@ impl<'a> CellGrid<'a> {
pub fn has_repeated_headers(&self) -> bool { pub fn has_repeated_headers(&self) -> bool {
self.headers.iter().any(|h| h.repeated) self.headers.iter().any(|h| h.repeated)
} }
#[inline]
pub fn has_repeated_footers(&self) -> bool {
self.footers.iter().any(|f| f.repeated)
}
} }
/// Resolves and positions all cells in the grid before creating it. /// Resolves and positions all cells in the grid before creating it.
@ -977,6 +982,7 @@ struct RowGroupData {
/// ///
/// This stays as `None` for fully empty headers and footers. /// This stays as `None` for fully empty headers and footers.
range: Option<Range<usize>>, range: Option<Range<usize>>,
#[allow(dead_code)] // TODO: should we remove this?
span: Span, span: Span,
kind: RowGroupKind, kind: RowGroupKind,
@ -1034,15 +1040,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
let has_gutter = self.gutter.any(|tracks| !tracks.is_empty()); let has_gutter = self.gutter.any(|tracks| !tracks.is_empty());
let mut headers: Vec<Repeatable<Header>> = vec![]; let mut headers: Vec<Repeatable<Header>> = vec![];
let mut footers: Vec<Repeatable<Footer>> = vec![];
// Stores where the footer is supposed to end, its span, and the // The first and last rows containing a cell outside a row group, that
// actual footer structure. // is, outside a header or footer. Headers after the last such row and
let mut footer: Option<(usize, Span, Footer)> = None; // footers before the first such row have no "children" cells and thus
let mut repeat_footer = false; // are not repeated.
let mut first_last_cell_rows = None;
// If true, there has been at least one cell besides headers and
// footers. When false, footers at the end are forced to not repeat.
let mut at_least_one_cell = false;
// We can't just use the cell's index in the 'cells' vector to // We can't just use the cell's index in the 'cells' vector to
// determine its automatic position, since cells could have arbitrary // determine its automatic position, since cells could have arbitrary
@ -1060,10 +1064,11 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// automatically-positioned cell. // automatically-positioned cell.
let mut auto_index: usize = 0; let mut auto_index: usize = 0;
// The next header after the latest auto-positioned cell. This is used // The next header and footer after the latest auto-positioned cell.
// to avoid checking for collision with headers that were already // These are used to avoid checking for collision with headers that
// skipped. // were already skipped.
let mut next_header = 0; let mut next_header = 0;
let mut next_footer = 0;
// We have to rebuild the grid to account for fixed cell positions. // We have to rebuild the grid to account for fixed cell positions.
// //
@ -1086,12 +1091,12 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
&mut pending_hlines, &mut pending_hlines,
&mut pending_vlines, &mut pending_vlines,
&mut headers, &mut headers,
&mut footer, &mut footers,
&mut repeat_footer,
&mut auto_index, &mut auto_index,
&mut next_header, &mut next_header,
&mut next_footer,
&mut resolved_cells, &mut resolved_cells,
&mut at_least_one_cell, &mut first_last_cell_rows,
child, child,
)?; )?;
} }
@ -1107,13 +1112,12 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
row_amount, row_amount,
)?; )?;
let footer = self.finalize_headers_and_footers( self.finalize_headers_and_footers(
has_gutter, has_gutter,
&mut headers, &mut headers,
footer, &mut footers,
repeat_footer,
row_amount, row_amount,
at_least_one_cell, first_last_cell_rows,
)?; )?;
Ok(CellGrid::new_internal( Ok(CellGrid::new_internal(
@ -1122,7 +1126,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
vlines, vlines,
hlines, hlines,
headers, headers,
footer, footers,
resolved_cells, resolved_cells,
)) ))
} }
@ -1142,12 +1146,12 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
pending_hlines: &mut Vec<(Span, Line, bool)>, pending_hlines: &mut Vec<(Span, Line, bool)>,
pending_vlines: &mut Vec<(Span, Line)>, pending_vlines: &mut Vec<(Span, Line)>,
headers: &mut Vec<Repeatable<Header>>, headers: &mut Vec<Repeatable<Header>>,
footer: &mut Option<(usize, Span, Footer)>, footers: &mut Vec<Repeatable<Footer>>,
repeat_footer: &mut bool,
auto_index: &mut usize, auto_index: &mut usize,
next_header: &mut usize, next_header: &mut usize,
next_footer: &mut usize,
resolved_cells: &mut Vec<Option<Entry<'x>>>, resolved_cells: &mut Vec<Option<Entry<'x>>>,
at_least_one_cell: &mut bool, first_last_cell_rows: &mut Option<(usize, usize)>,
child: ResolvableGridChild<T, I>, child: ResolvableGridChild<T, I>,
) -> SourceResult<()> ) -> SourceResult<()>
where where
@ -1198,6 +1202,12 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
&mut (*next_header).clone() &mut (*next_header).clone()
}; };
let local_next_footer = if matches!(child, ResolvableGridChild::Item(_)) {
next_footer
} else {
&mut (*next_footer).clone()
};
// The first row in which this table group can fit. // The first row in which this table group can fit.
// //
// Within headers and footers, this will correspond to the first // Within headers and footers, this will correspond to the first
@ -1207,7 +1217,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
let mut first_available_row = 0; let mut first_available_row = 0;
let (header_footer_items, simple_item) = match child { let (header_footer_items, simple_item) = match child {
ResolvableGridChild::Header { repeat, level, span, items, .. } => { ResolvableGridChild::Header { repeat, level, span, items } => {
row_group_data = Some(RowGroupData { row_group_data = Some(RowGroupData {
range: None, range: None,
span, span,
@ -1234,17 +1244,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
(Some(items), None) (Some(items), None)
} }
ResolvableGridChild::Footer { repeat, span, items, .. } => { ResolvableGridChild::Footer { repeat, level, span, items } => {
if footer.is_some() {
bail!(span, "cannot have more than one footer");
}
row_group_data = Some(RowGroupData { row_group_data = Some(RowGroupData {
range: None, range: None,
span, span,
repeat, repeat,
kind: RowGroupKind::Footer, kind: RowGroupKind::Footer,
repeatable_level: NonZeroU32::ONE, repeatable_level: level,
top_hlines_start: pending_hlines.len(), top_hlines_start: pending_hlines.len(),
top_hlines_end: None, top_hlines_end: None,
}); });
@ -1256,13 +1262,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
(Some(items), None) (Some(items), None)
} }
ResolvableGridChild::Item(item) => { ResolvableGridChild::Item(item) => (None, Some(item)),
if matches!(item, ResolvableGridItem::Cell(_)) {
*at_least_one_cell = true;
}
(None, Some(item))
}
}; };
let items = header_footer_items.into_iter().flatten().chain(simple_item); let items = header_footer_items.into_iter().flatten().chain(simple_item);
@ -1382,10 +1382,11 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
colspan, colspan,
rowspan, rowspan,
headers, headers,
footer.as_ref(), footers,
resolved_cells, resolved_cells,
local_auto_index, local_auto_index,
local_next_header, local_next_header,
local_next_footer,
first_available_row, first_available_row,
columns, columns,
row_group_data.is_some(), row_group_data.is_some(),
@ -1443,6 +1444,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// no longer appear at the top. // no longer appear at the top.
*top_hlines_end = Some(pending_hlines.len()); *top_hlines_end = Some(pending_hlines.len());
} }
} else {
// This is a cell outside a row group.
*first_last_cell_rows = Some(
first_last_cell_rows
.map(|(first, last)| (first.min(y), last.max(y)))
.unwrap_or((y, y)),
);
} }
// Let's resolve the cell so it can determine its own fields // Let's resolve the cell so it can determine its own fields
@ -1607,23 +1615,18 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
RowGroupKind::Footer => { RowGroupKind::Footer => {
// Only check if the footer is at the end later, once we know // Only check if the footer is at the end later, once we know
// the final amount of rows. // the final amount of rows.
*footer = Some(( let data = Footer {
group_range.end, // Later on, we have to correct this range in case there
row_group.span, // is gutter, but only once all cells have been analyzed
Footer { // and the header's and footer's exact boundaries are
// Later on, we have to correct this number in case there // known. That is because the gutter row immediately
// is gutter, but only once all cells have been analyzed // before the footer might not be included as part of
// and the header's and footer's exact boundaries are // the footer if it is contained within the header.
// known. That is because the gutter row immediately range: group_range,
// before the footer might not be included as part of level: row_group.repeatable_level.get(),
// the footer if it is contained within the header. };
start: group_range.start,
end: group_range.end,
level: 1,
},
));
*repeat_footer = row_group.repeat; footers.push(Repeatable { inner: data, repeated: row_group.repeat });
} }
} }
} }
@ -1788,36 +1791,41 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
/// an adjacent gutter row to be repeated alongside that header or /// an adjacent gutter row to be repeated alongside that header or
/// footer, if there is gutter; /// footer, if there is gutter;
/// 3. Wrap headers and footers in the correct [`Repeatable`] variant. /// 3. Wrap headers and footers in the correct [`Repeatable`] variant.
#[allow(clippy::type_complexity)]
fn finalize_headers_and_footers( fn finalize_headers_and_footers(
&self, &self,
has_gutter: bool, has_gutter: bool,
headers: &mut [Repeatable<Header>], headers: &mut [Repeatable<Header>],
footer: Option<(usize, Span, Footer)>, footers: &mut [Repeatable<Footer>],
repeat_footer: bool,
row_amount: usize, row_amount: usize,
at_least_one_cell: bool, first_last_cell_rows: Option<(usize, usize)>,
) -> SourceResult<Option<Repeatable<Footer>>> { ) -> SourceResult<()> {
// Mark consecutive headers right before the end of the table, or the // Mark consecutive headers right before the end of the table, or the
// final footer, as short lived, given that there are no normal rows // footers at the end, as short lived, given that there are no normal
// after them, so repeating them is pointless. // rows after them, so repeating them is pointless.
// //
// It is important to do this BEFORE we update header and footer ranges // Same for consecutive footers right after the start of the table or
// due to gutter below as 'row_amount' doesn't consider gutter. // any initial headers.
// if let Some((first_cell_row, last_cell_row)) = first_last_cell_rows {
// TODO(subfooters): take the last footer if it is at the end and for header in
// backtrack through consecutive footers until the first one in the headers.iter_mut().rev().take_while(|h| h.range.start > last_cell_row)
// sequence is found. If there is no footer at the end, there are no {
// haeders to turn short-lived. header.short_lived = true;
let mut consecutive_header_start = }
footer.as_ref().map(|(_, _, f)| f.start).unwrap_or(row_amount);
for header_at_the_end in headers.iter_mut().rev().take_while(move |h| {
let at_the_end = h.range.end == consecutive_header_start;
consecutive_header_start = h.range.start; for footer in footers.iter_mut().take_while(|f| f.range.end <= first_cell_row)
at_the_end {
}) { // TODO(subfooters): short lived
header_at_the_end.short_lived = true; footer.repeated = false;
}
} else {
// No cells outside headers or footers, so nobody repeats!
for header in &mut *headers {
header.short_lived = true;
}
for footer in &mut *footers {
// TODO(subfooters): short lived
footer.repeated = false;
}
} }
// Repeat the gutter below a header (hence why we don't // Repeat the gutter below a header (hence why we don't
@ -1849,14 +1857,8 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
let row_amount = (2 * row_amount).saturating_sub(1); let row_amount = (2 * row_amount).saturating_sub(1);
header.range.end = header.range.end.min(row_amount); header.range.end = header.range.end.min(row_amount);
} }
}
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");
}
for footer in &mut *footers {
// TODO(subfooters): will need a global slice of headers and // TODO(subfooters): will need a global slice of headers and
// footers for when we have multiple footers // footers for when we have multiple footers
// Alternatively, never include the gutter in the footer's // Alternatively, never include the gutter in the footer's
@ -1869,45 +1871,32 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// out the footer for real, the mechanism can be disabled. // out the footer for real, the mechanism can be disabled.
let last_header_end = headers.last().map(|header| header.range.end); let last_header_end = headers.last().map(|header| header.range.end);
if has_gutter { // Convert the footer's start index to post-gutter coordinates.
// Convert the footer's start index to post-gutter coordinates. footer.range.start *= 2;
footer.start *= 2;
// Include the gutter right before the footer, unless there is // TODO: this probably has to change
// none, or the gutter is already included in the header (no // Include the gutter right before the footer, unless there is
// rows between the header and the footer). // none, or the gutter is already included in the header (no
if last_header_end != Some(footer.start) { // rows between the header and the footer).
footer.start = footer.start.saturating_sub(1); if last_header_end != Some(footer.range.start) {
} footer.range.start = footer.range.start.saturating_sub(1);
// Adapt footer end but DO NOT include the gutter below it,
// if it exists. Calculation:
// - Starts as 'last y + 1'.
// - The result will be
// 2 * (last_y + 1) - 1 = 2 * last_y + 1,
// which is the new index of the last footer row plus one,
// meaning we do exclude any gutter below this way.
//
// It also keeps us within the total amount of rows, so we
// don't need to '.min()' later.
footer.end = (2 * footer.end).saturating_sub(1);
} }
Ok(footer) // Adapt footer end but DO NOT include the gutter below it,
}) // if it exists. Calculation:
.transpose()? // - Starts as 'last y + 1'.
.map(|footer| { // - The result will be
// Don't repeat footers when the table only has headers and // 2 * (last_y + 1) - 1 = 2 * last_y + 1,
// footers. // which is the new index of the last footer row plus one,
// TODO(subfooters): Switch this to marking the last N // meaning we do exclude any gutter below this way.
// consecutive footers as short lived. //
Repeatable { // It also keeps us within the total amount of rows, so we
inner: footer, // don't need to '.min()' later.
repeated: repeat_footer && at_least_one_cell, footer.range.end = (2 * footer.range.end).saturating_sub(1);
} }
}); }
Ok(footer) Ok(())
} }
/// Resolves the cell's fields based on grid-wide properties. /// Resolves the cell's fields based on grid-wide properties.
@ -2079,7 +2068,7 @@ fn expand_row_group(
/// Check if a cell's fixed row would conflict with a header or footer. /// Check if a cell's fixed row would conflict with a header or footer.
fn check_for_conflicting_cell_row( fn check_for_conflicting_cell_row(
headers: &[Repeatable<Header>], headers: &[Repeatable<Header>],
footer: Option<&(usize, Span, Footer)>, footers: &[Repeatable<Footer>],
cell_y: usize, cell_y: usize,
rowspan: usize, rowspan: usize,
) -> HintedStrResult<()> { ) -> HintedStrResult<()> {
@ -2098,13 +2087,14 @@ fn check_for_conflicting_cell_row(
); );
} }
if let Some((_, _, footer)) = footer { if footers
if cell_y < footer.end && cell_y + rowspan > footer.start { .iter()
bail!( .any(|footer| cell_y < footer.range.end && cell_y + rowspan > footer.range.start)
"cell would conflict with footer spanning the same position"; {
hint: "try reducing the cell's rowspan or moving the footer" bail!(
); "cell would conflict with footer spanning the same position";
} hint: "try reducing the cell's rowspan or moving the footer"
);
} }
Ok(()) Ok(())
@ -2126,10 +2116,11 @@ fn resolve_cell_position(
colspan: usize, colspan: usize,
rowspan: usize, rowspan: usize,
headers: &[Repeatable<Header>], headers: &[Repeatable<Header>],
footer: Option<&(usize, Span, Footer)>, footers: &[Repeatable<Footer>],
resolved_cells: &[Option<Entry>], resolved_cells: &[Option<Entry>],
auto_index: &mut usize, auto_index: &mut usize,
next_header: &mut usize, next_header: &mut usize,
next_footer: &mut usize,
first_available_row: usize, first_available_row: usize,
columns: usize, columns: usize,
in_row_group: bool, in_row_group: bool,
@ -2152,11 +2143,12 @@ fn resolve_cell_position(
// simply skipping existing cells, headers and footers. // simply skipping existing cells, headers and footers.
let resolved_index = find_next_available_position( let resolved_index = find_next_available_position(
headers, headers,
footer, footers,
resolved_cells, resolved_cells,
columns, columns,
*auto_index, *auto_index,
next_header, next_header,
next_footer,
false, false,
)?; )?;
@ -2193,7 +2185,7 @@ fn resolve_cell_position(
// footer (but only if it isn't already in one, otherwise there // footer (but only if it isn't already in one, otherwise there
// will already be a separate check). // will already be a separate check).
if !in_row_group { if !in_row_group {
check_for_conflicting_cell_row(headers, footer, cell_y, rowspan)?; check_for_conflicting_cell_row(headers, footers, cell_y, rowspan)?;
} }
cell_index(cell_x, cell_y) cell_index(cell_x, cell_y)
@ -2212,25 +2204,26 @@ fn resolve_cell_position(
// cell in. // cell in.
find_next_available_position( find_next_available_position(
headers, headers,
footer, footers,
resolved_cells, resolved_cells,
columns, columns,
initial_index, initial_index,
// Make our own copy of the 'next_header' counter, since it // Make new copies of the 'next_header/footer' counters,
// should only be updated by auto cells. However, we cannot // since they should only be updated by auto cells.
// start with the same value as we are searching from the // However, we cannot start with the same values as we are
// start, and not from 'auto_index', so auto cells might // searching from the start, and not from 'auto_index', so
// have skipped some headers already which this cell will // auto cells might have skipped some headers and footers
// also need to skip. // already which this cell will also need to skip.
// //
// We could, in theory, keep a separate 'next_header' // We could, in theory, keep separate 'next_header/footer'
// counter for cells with fixed columns. But then we would // counters for cells with fixed columns. But then we would
// need one for every column, and much like how there isn't // need one for every column, and much like how there isn't
// an index counter for each column either, the potential // an index counter for each column either, the potential
// speed gain seems less relevant for a less used feature. // speed gain seems less relevant for a less used feature.
// Still, it is something to consider for the future if // Still, it is something to consider for the future if
// this turns out to be a bottleneck in important cases. // this turns out to be a bottleneck in important cases.
&mut 0, &mut 0,
&mut 0,
true, true,
) )
} }
@ -2241,7 +2234,7 @@ fn resolve_cell_position(
// footer (but only if it isn't already in one, otherwise there // footer (but only if it isn't already in one, otherwise there
// will already be a separate check). // will already be a separate check).
if !in_row_group { if !in_row_group {
check_for_conflicting_cell_row(headers, footer, cell_y, rowspan)?; check_for_conflicting_cell_row(headers, footers, cell_y, rowspan)?;
} }
// Let's find the first column which has that row available. // Let's find the first column which has that row available.
@ -2276,14 +2269,16 @@ fn resolve_cell_position(
/// ///
/// When `skip_rows` is true, one row is skipped on each iteration, preserving /// When `skip_rows` is true, one row is skipped on each iteration, preserving
/// the column. That is used to find a position for a fixed column cell. /// the column. That is used to find a position for a fixed column cell.
#[allow(clippy::too_many_arguments)]
#[inline] #[inline]
fn find_next_available_position( fn find_next_available_position(
headers: &[Repeatable<Header>], headers: &[Repeatable<Header>],
footer: Option<&(usize, Span, Footer)>, footers: &[Repeatable<Footer>],
resolved_cells: &[Option<Entry<'_>>], resolved_cells: &[Option<Entry<'_>>],
columns: usize, columns: usize,
initial_index: usize, initial_index: usize,
next_header: &mut usize, next_header: &mut usize,
next_footer: &mut usize,
skip_rows: bool, skip_rows: bool,
) -> HintedStrResult<usize> { ) -> HintedStrResult<usize> {
let mut resolved_index = initial_index; let mut resolved_index = initial_index;
@ -2327,15 +2322,20 @@ fn find_next_available_position(
// From now on, only check the headers afterwards. // From now on, only check the headers afterwards.
*next_header += 1; *next_header += 1;
} else if let Some((footer_end, _, _)) = footer.filter(|(end, _, footer)| { } else if let Some(footer) = footers
resolved_index >= footer.start * columns && resolved_index < *end * columns .get(*next_footer)
}) { .filter(|footer| resolved_index >= footer.range.start * columns)
{
// Skip footer, for the same reason. // Skip footer, for the same reason.
resolved_index = *footer_end * columns; if resolved_index < footer.range.end * columns {
resolved_index = footer.range.end * columns;
if skip_rows { if skip_rows {
resolved_index += initial_index % columns; resolved_index += initial_index % columns;
}
} }
*next_footer += 1;
} else { } else {
return Ok(resolved_index); return Ok(resolved_index);
} }
@ -2389,3 +2389,50 @@ fn skip_auto_index_through_fully_merged_rows(
} }
} }
} }
/// Generates a vector where all footers are sorted ahead of time by the points
/// at which they start repeating. When a new footer is about to be laid out,
/// conflicting footers which come before it in this vector must stop
/// repeating.
fn simulate_footer_repetition(footers: &[Repeatable<Footer>]) -> Vec<Repeatable<Footer>> {
if footers.len() <= 1 {
return footers.to_vec();
}
let mut ordered_footers = Vec::with_capacity(footers.len());
let mut repeating_footers: Vec<&Repeatable<Footer>> = vec![];
// Read footers in reverse, using the same algorithm as headers to
// determine when a footer starts and stops repeating, but going from grid
// end to start. When it stops repeating, that's when it will start
// repeating in proper layout (from start to end), whereas it starts
// repeating here when it should stop repeating in practice. So,
// effectively, repeated footer layout is the same as for headers, but
// reversed, which we take advantage of by doing it reversed and then
// reversing it all back later.
for footer in footers.iter().rev() {
// Keep only lower level footers. Assume sorted by increasing levels.
let stopped_repeating = repeating_footers
.drain(repeating_footers.partition_point(|f| f.level < footer.level)..);
// If they stopped repeating here, that's when they will start
// repeating. We save them in reverse of the reverse order so they stay
// sorted by increasing levels when we reverse `ordered_footers` later.
ordered_footers.extend(stopped_repeating.rev().cloned());
if footer.repeated {
// Start repeating now. Vector stays sorted by increasing levels,
// as any higher-level footers stopped repeating now.
repeating_footers.push(footer);
} else {
// Immediately finishes repeating.
ordered_footers.push(footer.clone());
}
}
// Push remaining footers that repeat starting from the top of the grid
ordered_footers.extend(repeating_footers.into_iter().rev().cloned());
ordered_footers.reverse();
ordered_footers
}

View File

@ -292,12 +292,35 @@ fn show_cellgrid_html(grid: CellGrid, styles: StyleChain) -> Content {
elem(tag::tr, Content::sequence(row)) elem(tag::tr, Content::sequence(row))
}; };
// TODO(subfooters): similarly to headers, take consecutive footers from // Store all consecutive headers at the start in 'tfoot'. All remaining
// the end for 'tfoot'. // headers are just normal rows across the table body. (There doesn't
let footer = grid.footer.map(|ft| { // appear to be an equivalent of 'th' for footers in HTML.)
let rows = rows.drain(ft.start..); // TODO: test
elem(tag::tfoot, Content::sequence(rows.map(|row| tr(tag::td, row)))) let footer = {
}); let mut consecutive_footer_start = grid.rows.len();
let footers_at_end = grid
.footers
.iter()
.rev()
.take_while(|ft| {
let is_consecutive = ft.range.end == consecutive_footer_start;
consecutive_footer_start = ft.range.start;
is_consecutive
})
.count();
if footers_at_end > 0 {
let last_mid_table_footer = grid.footers.len() - footers_at_end;
let removed_footer_rows =
grid.footers.get(last_mid_table_footer).unwrap().range.start;
let rows = rows.drain(removed_footer_rows..);
Some(elem(tag::tfoot, Content::sequence(rows.map(|row| tr(tag::td, row)))))
} else {
None
}
};
// Store all consecutive headers at the start in 'thead'. All remaining // Store all consecutive headers at the start in 'thead'. All remaining
// headers are just 'th' rows across the table body. // headers are just 'th' rows across the table body.
@ -567,6 +590,16 @@ pub struct TableFooter {
#[default(true)] #[default(true)]
pub repeat: bool, pub repeat: bool,
/// The level of the footer. Must not be zero.
///
/// This allows repeating multiple footers at once. Footers with different
/// levels can repeat together, as long as they have descending levels.
///
/// Notably, when a footer with a lower level stops repeating, all higher
/// or equal level headers start repeating, replacing the previous footer.
#[default(NonZeroU32::ONE)]
pub level: NonZeroU32,
/// The cells and lines within the footer. /// The cells and lines within the footer.
#[variadic] #[variadic]
pub children: Vec<TableItem>, pub children: Vec<TableItem>,

View File

@ -205,7 +205,7 @@
single or double quotes. single or double quotes.
The value is always of type [string]($str). More complex data The value is always of type [string]($str). More complex data
may be parsed manually using functions like [`json.decode`]($json.decode). may be parsed manually using functions like [`json`]($json).
- name: sym - name: sym
title: General title: General

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 549 B

View File

@ -41,6 +41,20 @@
) )
) )
--- grid-footer-gutter-short-lived ---
// Gutter, no repetition, short-lived
#set page(height: 6em)
#set text(6pt)
#set table(inset: 2pt, stroke: 0.5pt)
#table(
gutter: 2pt,
align: center + horizon,
table.header([a]),
table.footer([b]),
table.footer([c]),
[d],
)
--- grid-cell-override-in-header-and-footer --- --- grid-cell-override-in-header-and-footer ---
#table( #table(
table.header(table.cell(stroke: red)[Hello]), table.header(table.cell(stroke: red)[Hello]),
@ -89,7 +103,6 @@
stroke: black, stroke: black,
inset: 5pt, inset: 5pt,
grid.cell(x: 1)[a], grid.cell(x: 1)[a],
// Error: 3-56 footer must end at the last row
grid.footer(grid.cell(x: 0)[b1], grid.cell(x: 0)[b2]), grid.footer(grid.cell(x: 0)[b1], grid.cell(x: 0)[b2]),
// This should skip the footer // This should skip the footer
grid.cell(x: 1)[c] grid.cell(x: 1)[c]
@ -141,14 +154,12 @@
) )
--- grid-footer-not-at-last-row --- --- grid-footer-not-at-last-row ---
// Error: 2:3-2:19 footer must end at the last row
#grid( #grid(
grid.footer([a]), grid.footer([a]),
[b], [b],
) )
--- grid-footer-not-at-last-row-two-columns --- --- grid-footer-not-at-last-row-two-columns ---
// Error: 3:3-3:19 footer must end at the last row
#grid( #grid(
columns: 2, columns: 2,
grid.footer([a]), grid.footer([a]),
@ -166,7 +177,6 @@
) )
--- grid-footer-multiple --- --- grid-footer-multiple ---
// Error: 4:3-4:19 cannot have more than one footer
#grid( #grid(
[a], [a],
grid.footer([a]), grid.footer([a]),

View File

@ -0,0 +1,182 @@
--- grid-subfooters-demo ---
#set page(height: 15.2em)
#table(
columns: 2,
align: center,
table.header(
table.cell(colspan: 2)[*Regional User Data*],
),
table.header(
level: 2,
table.cell(colspan: 2)[*Germany*],
[*Username*], [*Joined*]
),
[john123], [2024],
[rob8], [2025],
[joe1], [2025],
[joe2], [2025],
[martha], [2025],
[pear], [2025],
table.footer(
level: 2,
[*Mode*], [2025],
table.cell(colspan: 2)[*Totals*],
),
// TODO: Why does it overflow here?
table.header(
level: 2,
table.cell(colspan: 2)[*United States*],
[*Username*], [*Joined*]
),
[cool4], [2023],
[roger], [2023],
[bigfan55], [2022],
table.footer(
level: 2,
[*Mode*], [2023],
table.cell(colspan: 2)[*Totals*],
),
table.footer(
table.cell(colspan: 2)[*Data Inc.*],
),
)
--- grid-subfooters-basic ---
#grid(
[a],
grid.footer(level: 2, [b]),
grid.footer([c]),
)
--- grid-subfooters-basic-non-consecutive ---
#grid(
[x],
grid.footer(level: 2, [a]),
[y],
grid.footer([b]),
)
--- grid-subfooters-basic-replace ---
#grid(
[x],
grid.footer(level: 2, [a]),
[y],
grid.footer(level: 2, [b]),
[z],
grid.footer([c]),
)
--- grid-subfooters-basic-with-header ---
#grid(
grid.header([a]),
[b],
grid.footer(level: 2, [c]),
grid.footer([d]),
)
--- grid-subfooters-basic-non-consecutive-with-header ---
#grid(
grid.header([a]),
[x],
grid.footer(level: 2, [b]),
[y],
grid.footer([f])
)
--- grid-subfooters-repeat ---
#set page(height: 8em)
#grid(
..([a],) * 10,
grid.footer(level: 2, [b]),
grid.footer([c]),
)
--- grid-subfooters-repeat-non-consecutive ---
#set page(height: 8em)
#grid(
..([y],) * 10,
grid.footer(level: 2, [b]),
[x],
grid.footer([a]),
)
--- grid-subfooters-repeat-with-header ---
#set page(height: 8em)
#grid(
grid.header([a]),
..([b],) * 10,
grid.footer(level: 2, [c]),
[m],
grid.footer([f])
)
--- grid-subfooters-repeat-gutter ---
// Gutter above the footer is also repeated
#set page(height: 8em)
#grid(
inset: (top: 0.5pt),
stroke: (top: 1pt),
gutter: (1pt,) * 9 + (6pt, 1pt),
..([a],) * 10,
grid.footer(level: 2, [b]),
grid.footer([c]),
)
--- grid-subfooters-repeat-replace ---
#set page(height: 8em)
#grid(
..([x],) * 10,
grid.footer(level: 2, [a]),
..([y],) * 10,
grid.footer(level: 2, [b]),
[z],
grid.footer([c]),
)
--- grid-subfooters-repeat-replace-multiple-levels ---
// TODO: This is overflowing
#set page(height: 8em)
#grid(
..([x],) * 6,
grid.footer(level: 2, [a]),
..([y],) * 10,
grid.footer(level: 3, [b]),
grid.footer(level: 2, [c]),
[z],
grid.footer([d]),
)
--- grid-subfooters-repeat-replace-gutter ---
#set page(height: 8em)
#grid(
gutter: 3pt,
..([x],) * 3,
grid.footer(level: 2, [a]),
..([y],) * 8,
grid.footer(level: 2, [b]),
[z],
grid.footer([c]),
)
--- grid-subfooters-repeat-replace-widow ---
#set page(height: 8em)
#grid(
..([x],) * 14,
grid.footer(level: 2, [a]),
..([y],) * 8,
grid.footer(level: 2, [b]),
[z],
grid.footer([c]),
)
--- grid-subfooters-repeat-replace-double-widow ---
#set page(height: 8em)
#grid(
..([x],) * 12,
grid.footer(level: 3, [a]),
grid.footer(level: 2, [b]),
..([y],) * 11,
grid.footer(level: 2, [c]),
[z],
grid.footer([d]),
)