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::{
Cell, CellGrid, Header, LinePosition, Repeatable,
};
use typst_library::layout::resolve::Footer;
use typst_library::layout::{
Abs, Axes, Dir, Fr, Fragment, Frame, FrameItem, Length, Point, Region, Regions, Rel,
Size, Sizing,
@ -60,6 +61,16 @@ pub struct GridLayouter<'a> {
pub(super) pending_headers: &'a [Repeatable<Header>],
/// Next headers to be processed.
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.
///
/// 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
/// stop.
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 simulation occurs before any rows are laid out for a region.
@ -215,7 +232,7 @@ pub(super) enum Row {
impl Row {
/// Returns the `y` index of this row.
fn index(&self) -> usize {
pub(super) fn index(&self) -> usize {
match self {
Self::Frame(_, y, _) => *y,
Self::Fr(_, y, _) => *y,
@ -253,6 +270,10 @@ impl<'a> GridLayouter<'a> {
repeating_headers: vec![],
upcoming_headers: &grid.headers,
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(),
current: Current {
initial: regions.size,
@ -264,6 +285,7 @@ impl<'a> GridLayouter<'a> {
lrows_orphan_snapshot: None,
repeating_header_height: Abs::zero(),
repeating_header_heights: vec![],
repeating_footer_heights: vec![],
footer_height: Abs::zero(),
},
span,
@ -274,15 +296,7 @@ impl<'a> GridLayouter<'a> {
pub fn layout(mut self, engine: &mut Engine) -> SourceResult<Fragment> {
self.measure_columns(engine)?;
if let Some(footer) = &self.grid.footer {
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;
}
}
self.prepare_next_repeating_footers(true, engine)?;
let mut y = 0;
let mut consecutive_header_count = 0;
@ -298,13 +312,15 @@ impl<'a> GridLayouter<'a> {
}
}
if let Some(footer) = &self.grid.footer {
if footer.repeated && y >= footer.start {
if y == footer.start {
self.layout_footer(footer, engine, self.finished.len())?;
self.flush_orphans();
}
y = footer.end;
if let [next_footer, other_footers @ ..] = self.upcoming_footers {
// TODO(subfooters): effective range (consider gutter before
// if it was removed)
if next_footer.range.contains(&y) {
self.upcoming_footers = other_footers;
self.place_new_footer(engine, next_footer)?;
self.flush_orphans();
y = next_footer.range.end;
continue;
}
}
@ -1566,26 +1582,34 @@ impl<'a> GridLayouter<'a> {
// TODO(subfooters): explicitly check for short-lived footers.
// TODO(subfooters): widow prevention for non-repeated footers with a
// similar mechanism / when implementing multiple footers.
let footer_would_be_widow = matches!(&self.grid.footer, Some(footer) if footer.repeated)
&& self.current.lrows.is_empty()
&& self.current.could_progress_at_top;
// TODO(subfooters): could progress check must be replaced to consider
// the presence of non-repeating footer (then always true).
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 !footer_would_be_widow {
if let Some(footer) = &self.grid.footer {
// Don't layout the footer if it would be alone with the header
// in the page (hence the widow check), and don't layout it
// twice (check below).
//
// TODO(subfooters): this check can be replaced by a vector of
// repeating footers in the future, and/or some "pending
// footers" vector for footers we're about to place.
if footer.repeated
&& self.current.lrows.iter().all(|row| row.index() < footer.start)
{
laid_out_footer_start = Some(footer.start);
self.layout_footer(footer, engine, self.finished.len())?;
}
if may_place_footers {
// Don't layout the footer if it would be alone with the header
// in the page (hence the widow check), and don't layout it
// twice (it is removed from repeating_footers once it is
// reached).
//
// Use index for iteration to avoid borrow conflict.
//
// Note that repeating footers are in reverse order.
//
// TODO(subfooters): "pending footers" vector for footers we're
// about to place. Needed for widow prevention of non-repeated
// footers.
let mut i = 0;
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).
// Any rowspans ending before this row are laid out even
// on this row's first frame.
if laid_out_footer_start.is_none_or(|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)
if (!may_place_footers
|| self.repeating_footers.iter().all(|footer| {
// If this is a footer row, then only lay out this rowspan
// if the rowspan is contained within the footer.
// Since the footer is a row from "the future", it
// 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
// it from the rowspans vector and lay it out.
@ -1732,25 +1768,18 @@ impl<'a> GridLayouter<'a> {
);
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();
if let Some(footer) =
self.grid.footer.as_ref().and_then(Repeatable::as_repeated)
{
self.prepare_footer(footer, engine, disambiguator)?;
if !self.repeating_footers.is_empty() {
// TODO(subfooters): let's not...
let footers = self.repeating_footers.clone();
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() {
// Add headers to the new region.
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.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() {
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.
// TODO(subfooters): do this properly (store footer rows)
let bottom_stroke_comes_from_footer = grid
.footer
.as_ref()
.footers
.last()
.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
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) =
@ -638,7 +641,7 @@ mod test {
vec![],
vec![],
vec![],
None,
vec![],
entries,
)
}
@ -1176,7 +1179,7 @@ mod test {
vec![],
vec![],
vec![],
None,
vec![],
entries,
)
}

View File

@ -240,16 +240,18 @@ impl<'a> GridLayouter<'a> {
self.current.initial_after_repeats = self.regions.size.y;
}
if let Some(footer) = &self.grid.footer {
if footer.repeated && skipped_region {
// Simulate the footer again; the region's 'full' might have
// changed.
self.regions.size.y += self.current.footer_height;
self.current.footer_height = self
.simulate_footer(footer, &self.regions, engine, disambiguator)?
.height;
self.regions.size.y -= self.current.footer_height;
}
if !self.repeating_footers.is_empty() && skipped_region {
// Simulate the footer again; the region's 'full' might have
// changed.
let (footer_height, footer_heights) = self.simulate_footer_heights(
self.repeating_footers.iter().copied(),
&self.regions,
engine,
disambiguator,
)?;
self.current.footer_height = footer_height;
self.current.repeating_footer_heights.extend(footer_heights);
}
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.
pub fn prepare_footer(
/// Place a footer we have reached through normal row layout.
pub fn place_new_footer(
&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,
disambiguator: usize,
) -> SourceResult<()> {
let footer_height = self
.simulate_footer(footer, &self.regions, engine, disambiguator)?
.height;
let (mut expected_footer_height, mut expected_footer_heights) = self
.simulate_footer_heights(
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;
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()
{
// Advance regions without any output until we can place the
// footer.
self.finish_region_internal(
Frame::soft(Axes::splat(Abs::zero())),
vec![],
Default::default(),
);
if at_region_top {
self.finish_region_internal(
Frame::soft(Axes::splat(Abs::zero())),
vec![],
Default::default(),
);
} else {
self.finish_region(engine, false)?;
}
skipped_region = true;
}
// TODO(subfooters): Consider resetting header height etc. if we skip
// 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 {
if skipped_region {
// Simulate the footer again; the region's 'full' might have
// changed.
self.simulate_footer(footer, &self.regions, engine, disambiguator)?
.height
} else {
footer_height
};
// changed, and the vector of heights was cleared.
(expected_footer_height, expected_footer_heights) = self
.simulate_footer_heights(footers, &self.regions, engine, disambiguator)?;
}
// 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(())
}
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.
/// They are unbreakable.
pub fn layout_footer(
&mut self,
footer: &Footer,
as_short_lived: bool,
engine: &mut Engine,
disambiguator: usize,
) -> 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.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;
let footer_len = footer.range.end - footer.range.start;
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(
y,
engine,
disambiguator,
RowState {
in_active_repeatable: repeats,
in_active_repeatable: !as_short_lived,
..Default::default()
},
)?;
@ -553,8 +724,8 @@ impl<'a> GridLayouter<'a> {
// assume that the amount of unbreakable rows following the first row
// in the footer will be precisely the rows in the footer.
self.simulate_unbreakable_row_group(
footer.start,
Some(footer.end - footer.start),
footer.range.start,
Some(footer.range.end - footer.range.start),
regions,
engine,
disambiguator,

View File

@ -234,24 +234,12 @@ impl GridLayouter<'_> {
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(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(
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,
engine,
0,
@ -400,7 +388,8 @@ impl GridLayouter<'_> {
if breakable
&& (!self.repeating_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
// when measuring the cell, including the last repeated region.
@ -1176,14 +1165,23 @@ impl<'a> RowspanSimulator<'a> {
(None, Abs::zero())
};
let footer_height = if let Some(footer) =
layouter.grid.footer.as_ref().and_then(Repeatable::as_repeated)
let (repeating_footers, footer_height) = if layouter.repeating_footers.is_empty()
{
layouter
.simulate_footer(footer, &self.regions, engine, disambiguator)?
.height
(None, Abs::zero())
} 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;
@ -1212,15 +1210,18 @@ impl<'a> RowspanSimulator<'a> {
};
}
if let Some(footer) =
layouter.grid.footer.as_ref().and_then(Repeatable::as_repeated)
{
if let Some(repeating_footers) = repeating_footers {
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, disambiguator)?
.height
.simulate_footer_heights(
repeating_footers,
&self.regions,
engine,
disambiguator,
)?
.0
} else {
footer_height
};

View File

@ -20,7 +20,7 @@ use crate::foundations::{
///
/// You can call a function by writing a comma-separated list of function
/// _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
/// would become empty, it can be omitted. Typst supports positional and named
/// arguments. The former are identified by position and type, while the latter

View File

@ -496,6 +496,16 @@ pub struct GridFooter {
#[default(true)]
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.
#[variadic]
pub children: Vec<GridItem>,

View File

@ -54,6 +54,7 @@ pub fn grid_to_cellgrid<'a>(
},
GridChild::Footer(footer) => ResolvableGridChild::Footer {
repeat: footer.repeat(styles),
level: footer.level(styles),
span: footer.span(),
items: footer.children.iter().map(resolve_item),
},
@ -108,6 +109,7 @@ pub fn table_to_cellgrid<'a>(
},
TableChild::Footer(footer) => ResolvableGridChild::Footer {
repeat: footer.repeat(styles),
level: footer.level(styles),
span: footer.span(),
items: footer.children.iter().map(resolve_item),
},
@ -445,31 +447,22 @@ pub struct Header {
}
/// A repeatable grid footer. Stops at the last row.
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct Footer {
/// The first row included in this footer.
pub start: usize,
/// The index after the last row included in this footer.
pub end: usize,
/// The range of rows included in this footer.
pub range: Range<usize>,
/// The footer's level.
///
/// Used similarly to header level.
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).
///
/// 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).
#[derive(Debug, Clone)]
pub struct Repeatable<T> {
inner: T,
@ -656,7 +649,7 @@ impl<'a> Entry<'a> {
/// Any grid child, which can be either a header or an item.
pub enum ResolvableGridChild<T: ResolvableCell, 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>),
}
@ -678,8 +671,12 @@ pub struct CellGrid<'a> {
pub hlines: Vec<Vec<Line>>,
/// The repeatable headers of this grid.
pub headers: Vec<Repeatable<Header>>,
/// The repeatable footer of this grid.
pub footer: Option<Repeatable<Footer>>,
/// The repeatable footers of this grid.
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.
pub has_gutter: bool,
}
@ -692,7 +689,7 @@ impl<'a> CellGrid<'a> {
cells: impl IntoIterator<Item = Cell<'a>>,
) -> Self {
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.
@ -702,7 +699,7 @@ impl<'a> CellGrid<'a> {
vlines: Vec<Vec<Line>>,
hlines: Vec<Vec<Line>>,
headers: Vec<Repeatable<Header>>,
footer: Option<Repeatable<Footer>>,
footers: Vec<Repeatable<Footer>>,
entries: Vec<Entry<'a>>,
) -> Self {
let mut cols = vec![];
@ -749,6 +746,8 @@ impl<'a> CellGrid<'a> {
rows.pop();
}
let sorted_footers = simulate_footer_repetition(&footers);
Self {
cols,
rows,
@ -756,7 +755,8 @@ impl<'a> CellGrid<'a> {
vlines,
hlines,
headers,
footer,
footers,
sorted_footers,
has_gutter,
}
}
@ -895,6 +895,11 @@ impl<'a> CellGrid<'a> {
pub fn has_repeated_headers(&self) -> bool {
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.
@ -977,6 +982,7 @@ struct RowGroupData {
///
/// This stays as `None` for fully empty headers and footers.
range: Option<Range<usize>>,
#[allow(dead_code)] // TODO: should we remove this?
span: Span,
kind: RowGroupKind,
@ -1034,15 +1040,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
let has_gutter = self.gutter.any(|tracks| !tracks.is_empty());
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
// actual footer structure.
let mut footer: Option<(usize, Span, Footer)> = None;
let mut repeat_footer = false;
// 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;
// The first and last rows containing a cell outside a row group, that
// is, outside a header or footer. Headers after the last such row and
// footers before the first such row have no "children" cells and thus
// are not repeated.
let mut first_last_cell_rows = None;
// We can't just use the cell's index in the 'cells' vector to
// determine its automatic position, since cells could have arbitrary
@ -1060,10 +1064,11 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// automatically-positioned cell.
let mut auto_index: usize = 0;
// The next header after the latest auto-positioned cell. This is used
// to avoid checking for collision with headers that were already
// skipped.
// The next header and footer after the latest auto-positioned cell.
// These are used to avoid checking for collision with headers that
// were already skipped.
let mut next_header = 0;
let mut next_footer = 0;
// 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_vlines,
&mut headers,
&mut footer,
&mut repeat_footer,
&mut footers,
&mut auto_index,
&mut next_header,
&mut next_footer,
&mut resolved_cells,
&mut at_least_one_cell,
&mut first_last_cell_rows,
child,
)?;
}
@ -1107,13 +1112,12 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
row_amount,
)?;
let footer = self.finalize_headers_and_footers(
self.finalize_headers_and_footers(
has_gutter,
&mut headers,
footer,
repeat_footer,
&mut footers,
row_amount,
at_least_one_cell,
first_last_cell_rows,
)?;
Ok(CellGrid::new_internal(
@ -1122,7 +1126,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
vlines,
hlines,
headers,
footer,
footers,
resolved_cells,
))
}
@ -1142,12 +1146,12 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
pending_hlines: &mut Vec<(Span, Line, bool)>,
pending_vlines: &mut Vec<(Span, Line)>,
headers: &mut Vec<Repeatable<Header>>,
footer: &mut Option<(usize, Span, Footer)>,
repeat_footer: &mut bool,
footers: &mut Vec<Repeatable<Footer>>,
auto_index: &mut usize,
next_header: &mut usize,
next_footer: &mut usize,
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>,
) -> SourceResult<()>
where
@ -1198,6 +1202,12 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
&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.
//
// 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 (header_footer_items, simple_item) = match child {
ResolvableGridChild::Header { repeat, level, span, items, .. } => {
ResolvableGridChild::Header { repeat, level, span, items } => {
row_group_data = Some(RowGroupData {
range: None,
span,
@ -1234,17 +1244,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
(Some(items), None)
}
ResolvableGridChild::Footer { repeat, span, items, .. } => {
if footer.is_some() {
bail!(span, "cannot have more than one footer");
}
ResolvableGridChild::Footer { repeat, level, span, items } => {
row_group_data = Some(RowGroupData {
range: None,
span,
repeat,
kind: RowGroupKind::Footer,
repeatable_level: NonZeroU32::ONE,
repeatable_level: level,
top_hlines_start: pending_hlines.len(),
top_hlines_end: None,
});
@ -1256,13 +1262,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
(Some(items), None)
}
ResolvableGridChild::Item(item) => {
if matches!(item, ResolvableGridItem::Cell(_)) {
*at_least_one_cell = true;
}
(None, Some(item))
}
ResolvableGridChild::Item(item) => (None, Some(item)),
};
let items = header_footer_items.into_iter().flatten().chain(simple_item);
@ -1382,10 +1382,11 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
colspan,
rowspan,
headers,
footer.as_ref(),
footers,
resolved_cells,
local_auto_index,
local_next_header,
local_next_footer,
first_available_row,
columns,
row_group_data.is_some(),
@ -1443,6 +1444,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
// no longer appear at the top.
*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
@ -1607,23 +1615,18 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
RowGroupKind::Footer => {
// Only check if the footer is at the end later, once we know
// the final amount of rows.
*footer = Some((
group_range.end,
row_group.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: group_range.start,
end: group_range.end,
level: 1,
},
));
let data = Footer {
// Later on, we have to correct this range 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.
range: group_range,
level: row_group.repeatable_level.get(),
};
*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
/// footer, if there is gutter;
/// 3. Wrap headers and footers in the correct [`Repeatable`] variant.
#[allow(clippy::type_complexity)]
fn finalize_headers_and_footers(
&self,
has_gutter: bool,
headers: &mut [Repeatable<Header>],
footer: Option<(usize, Span, Footer)>,
repeat_footer: bool,
footers: &mut [Repeatable<Footer>],
row_amount: usize,
at_least_one_cell: bool,
) -> SourceResult<Option<Repeatable<Footer>>> {
first_last_cell_rows: Option<(usize, usize)>,
) -> SourceResult<()> {
// Mark consecutive headers right before the end of the table, or the
// final footer, as short lived, given that there are no normal rows
// after them, so repeating them is pointless.
// footers at the end, as short lived, given that there are no normal
// rows after them, so repeating them is pointless.
//
// It is important to do this BEFORE we update header and footer ranges
// due to gutter below as 'row_amount' doesn't consider gutter.
//
// TODO(subfooters): take the last footer if it is at the end and
// backtrack through consecutive footers until the first one in the
// sequence is found. If there is no footer at the end, there are no
// haeders to turn short-lived.
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;
// Same for consecutive footers right after the start of the table or
// any initial headers.
if let Some((first_cell_row, last_cell_row)) = first_last_cell_rows {
for header in
headers.iter_mut().rev().take_while(|h| h.range.start > last_cell_row)
{
header.short_lived = true;
}
consecutive_header_start = h.range.start;
at_the_end
}) {
header_at_the_end.short_lived = true;
for footer in footers.iter_mut().take_while(|f| f.range.end <= first_cell_row)
{
// TODO(subfooters): short lived
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
@ -1849,14 +1857,8 @@ impl<'x> CellGridResolver<'_, '_, 'x> {
let row_amount = (2 * row_amount).saturating_sub(1);
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
// footers for when we have multiple footers
// 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.
let last_header_end = headers.last().map(|header| header.range.end);
if has_gutter {
// Convert the footer's start index to post-gutter coordinates.
footer.start *= 2;
// Convert the footer's start index to post-gutter coordinates.
footer.range.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 last_header_end != Some(footer.start) {
footer.start = footer.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);
// TODO: this probably has to change
// 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 last_header_end != Some(footer.range.start) {
footer.range.start = footer.range.start.saturating_sub(1);
}
Ok(footer)
})
.transpose()?
.map(|footer| {
// Don't repeat footers when the table only has headers and
// footers.
// TODO(subfooters): Switch this to marking the last N
// consecutive footers as short lived.
Repeatable {
inner: footer,
repeated: repeat_footer && at_least_one_cell,
}
});
// 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.range.end = (2 * footer.range.end).saturating_sub(1);
}
}
Ok(footer)
Ok(())
}
/// 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.
fn check_for_conflicting_cell_row(
headers: &[Repeatable<Header>],
footer: Option<&(usize, Span, Footer)>,
footers: &[Repeatable<Footer>],
cell_y: usize,
rowspan: usize,
) -> HintedStrResult<()> {
@ -2098,13 +2087,14 @@ fn check_for_conflicting_cell_row(
);
}
if let Some((_, _, footer)) = footer {
if cell_y < footer.end && cell_y + rowspan > footer.start {
bail!(
"cell would conflict with footer spanning the same position";
hint: "try reducing the cell's rowspan or moving the footer"
);
}
if footers
.iter()
.any(|footer| cell_y < footer.range.end && cell_y + rowspan > footer.range.start)
{
bail!(
"cell would conflict with footer spanning the same position";
hint: "try reducing the cell's rowspan or moving the footer"
);
}
Ok(())
@ -2126,10 +2116,11 @@ fn resolve_cell_position(
colspan: usize,
rowspan: usize,
headers: &[Repeatable<Header>],
footer: Option<&(usize, Span, Footer)>,
footers: &[Repeatable<Footer>],
resolved_cells: &[Option<Entry>],
auto_index: &mut usize,
next_header: &mut usize,
next_footer: &mut usize,
first_available_row: usize,
columns: usize,
in_row_group: bool,
@ -2152,11 +2143,12 @@ fn resolve_cell_position(
// simply skipping existing cells, headers and footers.
let resolved_index = find_next_available_position(
headers,
footer,
footers,
resolved_cells,
columns,
*auto_index,
next_header,
next_footer,
false,
)?;
@ -2193,7 +2185,7 @@ fn resolve_cell_position(
// footer (but only if it isn't already in one, otherwise there
// will already be a separate check).
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)
@ -2212,25 +2204,26 @@ fn resolve_cell_position(
// cell in.
find_next_available_position(
headers,
footer,
footers,
resolved_cells,
columns,
initial_index,
// Make our own copy of the 'next_header' counter, since it
// should only be updated by auto cells. However, we cannot
// start with the same value as we are searching from the
// start, and not from 'auto_index', so auto cells might
// have skipped some headers already which this cell will
// also need to skip.
// Make new copies of the 'next_header/footer' counters,
// since they should only be updated by auto cells.
// However, we cannot start with the same values as we are
// searching from the start, and not from 'auto_index', so
// auto cells might have skipped some headers and footers
// already which this cell will also need to skip.
//
// We could, in theory, keep a separate 'next_header'
// counter for cells with fixed columns. But then we would
// We could, in theory, keep separate 'next_header/footer'
// counters for cells with fixed columns. But then we would
// need one for every column, and much like how there isn't
// an index counter for each column either, the potential
// speed gain seems less relevant for a less used feature.
// Still, it is something to consider for the future if
// this turns out to be a bottleneck in important cases.
&mut 0,
&mut 0,
true,
)
}
@ -2241,7 +2234,7 @@ fn resolve_cell_position(
// footer (but only if it isn't already in one, otherwise there
// will already be a separate check).
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.
@ -2276,14 +2269,16 @@ fn resolve_cell_position(
///
/// 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.
#[allow(clippy::too_many_arguments)]
#[inline]
fn find_next_available_position(
headers: &[Repeatable<Header>],
footer: Option<&(usize, Span, Footer)>,
footers: &[Repeatable<Footer>],
resolved_cells: &[Option<Entry<'_>>],
columns: usize,
initial_index: usize,
next_header: &mut usize,
next_footer: &mut usize,
skip_rows: bool,
) -> HintedStrResult<usize> {
let mut resolved_index = initial_index;
@ -2327,15 +2322,20 @@ fn find_next_available_position(
// From now on, only check the headers afterwards.
*next_header += 1;
} else if let Some((footer_end, _, _)) = footer.filter(|(end, _, footer)| {
resolved_index >= footer.start * columns && resolved_index < *end * columns
}) {
} else if let Some(footer) = footers
.get(*next_footer)
.filter(|footer| resolved_index >= footer.range.start * columns)
{
// 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 {
resolved_index += initial_index % columns;
if skip_rows {
resolved_index += initial_index % columns;
}
}
*next_footer += 1;
} else {
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))
};
// TODO(subfooters): similarly to headers, take consecutive footers from
// the end for 'tfoot'.
let footer = grid.footer.map(|ft| {
let rows = rows.drain(ft.start..);
elem(tag::tfoot, Content::sequence(rows.map(|row| tr(tag::td, row))))
});
// Store all consecutive headers at the start in 'tfoot'. All remaining
// headers are just normal rows across the table body. (There doesn't
// appear to be an equivalent of 'th' for footers in HTML.)
// TODO: test
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
// headers are just 'th' rows across the table body.
@ -567,6 +590,16 @@ pub struct TableFooter {
#[default(true)]
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.
#[variadic]
pub children: Vec<TableItem>,

View File

@ -205,7 +205,7 @@
single or double quotes.
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
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 ---
#table(
table.header(table.cell(stroke: red)[Hello]),
@ -89,7 +103,6 @@
stroke: black,
inset: 5pt,
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]),
// This should skip the footer
grid.cell(x: 1)[c]
@ -141,14 +154,12 @@
)
--- grid-footer-not-at-last-row ---
// Error: 2:3-2:19 footer must end at the last row
#grid(
grid.footer([a]),
[b],
)
--- grid-footer-not-at-last-row-two-columns ---
// Error: 3:3-3:19 footer must end at the last row
#grid(
columns: 2,
grid.footer([a]),
@ -166,7 +177,6 @@
)
--- grid-footer-multiple ---
// Error: 4:3-4:19 cannot have more than one footer
#grid(
[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]),
)