diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs index 08d0130da..bad25b474 100644 --- a/crates/typst-library/src/layout/grid/resolve.rs +++ b/crates/typst-library/src/layout/grid/resolve.rs @@ -1,4 +1,5 @@ use std::num::NonZeroUsize; +use std::ops::Range; use std::sync::Arc; use ecow::eco_format; @@ -20,6 +21,8 @@ use typst_library::Dir; use typst_syntax::Span; use typst_utils::NonZeroExt; +use crate::introspection::SplitLocator; + /// Convert a grid to a cell grid. #[typst_macros::time(span = elem.span())] pub fn grid_to_cellgrid<'a>( @@ -57,7 +60,7 @@ pub fn grid_to_cellgrid<'a>( ResolvableGridChild::Item(grid_item_to_resolvable(item, styles)) } }); - CellGrid::resolve( + resolve_cellgrid( tracks, gutter, locator, @@ -110,7 +113,7 @@ pub fn table_to_cellgrid<'a>( ResolvableGridChild::Item(table_item_to_resolvable(item, styles)) } }); - CellGrid::resolve( + resolve_cellgrid( tracks, gutter, locator, @@ -421,12 +424,14 @@ pub struct Line { } /// A repeatable grid header. Starts at the first row. +#[derive(Debug)] pub struct Header { /// The index after the last row included in this header. pub end: usize, } /// A repeatable grid footer. Stops at the last row. +#[derive(Debug)] pub struct Footer { /// The first row included in this footer. pub start: usize, @@ -652,772 +657,6 @@ impl<'a> CellGrid<'a> { Self::new_internal(tracks, gutter, vec![], vec![], None, None, entries) } - /// Resolves and positions all cells in the grid before creating it. - /// Allows them to keep track of their final properties and positions - /// and adjust their fields accordingly. - /// Cells must implement Clone as they will be owned. Additionally, they - /// must implement Default in order to fill positions in the grid which - /// weren't explicitly specified by the user with empty cells. - #[allow(clippy::too_many_arguments)] - pub fn resolve( - tracks: Axes<&[Sizing]>, - gutter: Axes<&[Sizing]>, - locator: Locator<'a>, - children: C, - fill: &Celled>, - align: &Celled>, - inset: &Celled>>>, - stroke: &ResolvedCelled>>>>, - engine: &mut Engine, - styles: StyleChain, - span: Span, - ) -> SourceResult - where - T: ResolvableCell + Default, - I: Iterator>, - C: IntoIterator>, - C::IntoIter: ExactSizeIterator, - { - let mut locator = locator.split(); - - // Number of content columns: Always at least one. - let c = tracks.x.len().max(1); - - // Lists of lines. - // Horizontal lines are only pushed later to be able to check for row - // validity, since the amount of rows isn't known until all items were - // analyzed in the for loop below. - // We keep their spans so we can report errors later. - // The additional boolean indicates whether the hline had an automatic - // 'y' index, and is used to change the index of hlines at the top of a - // header or footer. - let mut pending_hlines: Vec<(Span, Line, bool)> = vec![]; - - // For consistency, only push vertical lines later as well. - let mut pending_vlines: Vec<(Span, Line)> = vec![]; - let has_gutter = gutter.any(|tracks| !tracks.is_empty()); - - let mut header: Option
= None; - let mut repeat_header = false; - - // Stores where the footer is supposed to end, its span, and the - // actual footer structure. - let mut footer: Option<(usize, Span, Footer)> = None; - let mut repeat_footer = false; - - // Resolves the breakability of a cell. Cells that span at least one - // auto-sized row or gutter are considered breakable. - let resolve_breakable = |y, rowspan| { - let auto = Sizing::Auto; - let zero = Sizing::Rel(Rel::zero()); - tracks - .y - .iter() - .chain(std::iter::repeat(tracks.y.last().unwrap_or(&auto))) - .skip(y) - .take(rowspan) - .any(|row| row == &Sizing::Auto) - || gutter - .y - .iter() - .chain(std::iter::repeat(gutter.y.last().unwrap_or(&zero))) - .skip(y) - .take(rowspan - 1) - .any(|row_gutter| row_gutter == &Sizing::Auto) - }; - - // We can't just use the cell's index in the 'cells' vector to - // determine its automatic position, since cells could have arbitrary - // positions, so the position of a cell in 'cells' can differ from its - // final position in 'resolved_cells' (see below). - // Therefore, we use a counter, 'auto_index', to determine the position - // of the next cell with (x: auto, y: auto). It is only stepped when - // a cell with (x: auto, y: auto), usually the vast majority, is found. - let mut auto_index: usize = 0; - - // We have to rebuild the grid to account for arbitrary positions. - // Create at least 'children.len()' positions, since there could be at - // least 'children.len()' cells (if no explicit lines were specified), - // even though some of them might be placed in arbitrary positions and - // thus cause the grid to expand. - // Additionally, make sure we allocate up to the next multiple of 'c', - // since each row will have 'c' cells, even if the last few cells - // weren't explicitly specified by the user. - // We apply '% c' twice so that the amount of cells potentially missing - // is zero when 'children.len()' is already a multiple of 'c' (thus - // 'children.len() % c' would be zero). - let children = children.into_iter(); - let Some(child_count) = children.len().checked_add((c - children.len() % c) % c) - else { - bail!(span, "too many cells or lines were given") - }; - let mut resolved_cells: Vec> = Vec::with_capacity(child_count); - for child in children { - let mut is_header = false; - let mut is_footer = false; - let mut child_start = usize::MAX; - let mut child_end = 0; - let mut child_span = Span::detached(); - let mut start_new_row = false; - let mut first_index_of_top_hlines = usize::MAX; - let mut first_index_of_non_top_hlines = usize::MAX; - - let (header_footer_items, simple_item) = match child { - ResolvableGridChild::Header { repeat, span, items, .. } => { - if header.is_some() { - bail!(span, "cannot have more than one header"); - } - - is_header = true; - child_span = span; - repeat_header = repeat; - - // If any cell in the header is automatically positioned, - // have it skip to the next row. This is to avoid having a - // header after a partially filled row just add cells to - // that row instead of starting a new one. - // FIXME: Revise this approach when headers can start from - // arbitrary rows. - start_new_row = true; - - // Any hlines at the top of the header will start at this - // index. - first_index_of_top_hlines = pending_hlines.len(); - - (Some(items), None) - } - ResolvableGridChild::Footer { repeat, span, items, .. } => { - if footer.is_some() { - bail!(span, "cannot have more than one footer"); - } - - is_footer = true; - child_span = span; - repeat_footer = repeat; - - // If any cell in the footer is automatically positioned, - // have it skip to the next row. This is to avoid having a - // footer after a partially filled row just add cells to - // that row instead of starting a new one. - start_new_row = true; - - // Any hlines at the top of the footer will start at this - // index. - first_index_of_top_hlines = pending_hlines.len(); - - (Some(items), None) - } - ResolvableGridChild::Item(item) => (None, Some(item)), - }; - - let items = header_footer_items - .into_iter() - .flatten() - .chain(simple_item.into_iter()); - for item in items { - let cell = match item { - ResolvableGridItem::HLine { - y, - start, - end, - stroke, - span, - position, - } => { - let has_auto_y = y.is_auto(); - let y = y.unwrap_or_else(|| { - // Avoid placing the hline inside consecutive - // rowspans occupying all columns, as it'd just - // disappear, at least when there's no column - // gutter. - skip_auto_index_through_fully_merged_rows( - &resolved_cells, - &mut auto_index, - c, - ); - - // When no 'y' is specified for the hline, we place - // it under the latest automatically positioned - // cell. - // The current value of the auto index is always - // the index of the latest automatically positioned - // cell placed plus one (that's what we do in - // 'resolve_cell_position'), so we subtract 1 to - // get that cell's index, and place the hline below - // its row. The exception is when the auto_index is - // 0, meaning no automatically positioned cell was - // placed yet. In that case, we place the hline at - // the top of the table. - // - // Exceptionally, the hline will be placed before - // the minimum auto index if the current auto index - // from previous iterations is smaller than the - // minimum it should have for the current grid - // child. Effectively, this means that a hline at - // the start of a header will always appear above - // that header's first row. Similarly for footers. - auto_index - .checked_sub(1) - .map_or(0, |last_auto_index| last_auto_index / c + 1) - }); - if end.is_some_and(|end| end.get() < start) { - bail!(span, "line cannot end before it starts"); - } - let line = Line { index: y, start, end, stroke, position }; - - // Since the amount of rows is dynamic, delay placing - // hlines until after all cells were placed so we can - // properly verify if they are valid. Note that we - // can't place hlines even if we already know they - // would be in a valid row, since it's possible that we - // pushed pending hlines in the same row as this one in - // previous iterations, and we need to ensure that - // hlines from previous iterations are pushed to the - // final vector of hlines first - the order of hlines - // must be kept, as this matters when determining which - // one "wins" in case of conflict. Pushing the current - // hline before we push pending hlines later would - // change their order! - pending_hlines.push((span, line, has_auto_y)); - continue; - } - ResolvableGridItem::VLine { - x, - start, - end, - stroke, - span, - position, - } => { - let x = x.unwrap_or_else(|| { - // When no 'x' is specified for the vline, we place - // it after the latest automatically positioned - // cell. - // The current value of the auto index is always - // the index of the latest automatically positioned - // cell placed plus one (that's what we do in - // 'resolve_cell_position'), so we subtract 1 to - // get that cell's index, and place the vline after - // its column. The exception is when the auto_index - // is 0, meaning no automatically positioned cell - // was placed yet. In that case, we place the vline - // to the left of the table. - // - // Exceptionally, a vline is also placed to the - // left of the table if we should start a new row - // for the next automatically positioned cell. - // For example, this means that a vline at - // the beginning of a header will be placed to its - // left rather than after the previous - // automatically positioned cell. Same for footers. - auto_index - .checked_sub(1) - .filter(|_| !start_new_row) - .map_or(0, |last_auto_index| last_auto_index % c + 1) - }); - if end.is_some_and(|end| end.get() < start) { - bail!(span, "line cannot end before it starts"); - } - let line = Line { index: x, start, end, stroke, position }; - - // For consistency with hlines, we only push vlines to - // the final vector of vlines after processing every - // cell. - pending_vlines.push((span, line)); - continue; - } - ResolvableGridItem::Cell(cell) => cell, - }; - let cell_span = cell.span(); - let colspan = cell.colspan(styles).get(); - let rowspan = cell.rowspan(styles).get(); - // Let's calculate the cell's final position based on its - // requested position. - let resolved_index = { - let cell_x = cell.x(styles); - let cell_y = cell.y(styles); - resolve_cell_position( - cell_x, - cell_y, - colspan, - rowspan, - &resolved_cells, - &mut auto_index, - &mut start_new_row, - c, - ) - .at(cell_span)? - }; - let x = resolved_index % c; - let y = resolved_index / c; - - if colspan > c - x { - bail!( - cell_span, - "cell's colspan would cause it to exceed the available column(s)"; - hint: "try placing the cell in another position or reducing its colspan" - ) - } - - let Some(largest_index) = c - .checked_mul(rowspan - 1) - .and_then(|full_rowspan_offset| { - resolved_index.checked_add(full_rowspan_offset) - }) - .and_then(|last_row_pos| last_row_pos.checked_add(colspan - 1)) - else { - bail!( - cell_span, - "cell would span an exceedingly large position"; - hint: "try reducing the cell's rowspan or colspan" - ) - }; - - // Let's resolve the cell so it can determine its own fields - // based on its final position. - let cell = cell.resolve_cell( - x, - y, - &fill.resolve(engine, styles, x, y)?, - align.resolve(engine, styles, x, y)?, - inset.resolve(engine, styles, x, y)?, - stroke.resolve(engine, styles, x, y)?, - resolve_breakable(y, rowspan), - locator.next(&cell_span), - styles, - ); - - if largest_index >= resolved_cells.len() { - // Ensure the length of the vector of resolved cells is - // always a multiple of 'c' by pushing full rows every - // time. Here, we add enough absent positions (later - // converted to empty cells) to ensure the last row in the - // new vector length is completely filled. This is - // necessary so that those positions, even if not - // explicitly used at the end, are eventually susceptible - // to show rules and receive grid styling, as they will be - // resolved as empty cells in a second loop below. - let Some(new_len) = largest_index - .checked_add(1) - .and_then(|new_len| new_len.checked_add((c - new_len % c) % c)) - else { - bail!(cell_span, "cell position too large") - }; - - // Here, the cell needs to be placed in a position which - // doesn't exist yet in the grid (out of bounds). We will - // add enough absent positions for this to be possible. - // They must be absent as no cells actually occupy them - // (they can be overridden later); however, if no cells - // occupy them as we finish building the grid, then such - // positions will be replaced by empty cells. - resolved_cells.resize_with(new_len, || None); - } - - // The vector is large enough to contain the cell, so we can - // just index it directly to access the position it will be - // placed in. However, we still need to ensure we won't try to - // place a cell where there already is one. - let slot = &mut resolved_cells[resolved_index]; - if slot.is_some() { - bail!( - cell_span, - "attempted to place a second cell at column {x}, row {y}"; - hint: "try specifying your cells in a different order" - ); - } - - *slot = Some(Entry::Cell(cell)); - - // Now, if the cell spans more than one row or column, we fill - // the spanned positions in the grid with Entry::Merged - // pointing to the original cell as its parent. - for rowspan_offset in 0..rowspan { - let spanned_y = y + rowspan_offset; - let first_row_index = resolved_index + c * rowspan_offset; - for (colspan_offset, slot) in resolved_cells[first_row_index..] - [..colspan] - .iter_mut() - .enumerate() - { - let spanned_x = x + colspan_offset; - if spanned_x == x && spanned_y == y { - // This is the parent cell. - continue; - } - if slot.is_some() { - bail!( - cell_span, - "cell would span a previously placed cell at column {spanned_x}, row {spanned_y}"; - hint: "try specifying your cells in a different order or reducing the cell's rowspan or colspan" - ) - } - *slot = Some(Entry::Merged { parent: resolved_index }); - } - } - - if is_header || is_footer { - // Ensure each cell in a header or footer is fully - // contained within it. - child_start = child_start.min(y); - child_end = child_end.max(y + rowspan); - - if start_new_row && child_start <= auto_index.div_ceil(c) { - // No need to start a new row as we already include - // the row of the next automatically positioned cell in - // the header or footer. - start_new_row = false; - } - - if !start_new_row { - // From now on, upcoming hlines won't be at the top of - // the child, as the first automatically positioned - // cell was placed. - first_index_of_non_top_hlines = - first_index_of_non_top_hlines.min(pending_hlines.len()); - } - } - } - - if (is_header || is_footer) && child_start == usize::MAX { - // Empty header/footer: consider the header/footer to be - // at the next empty row after the latest auto index. - auto_index = find_next_empty_row(&resolved_cells, auto_index, c); - child_start = auto_index.div_ceil(c); - child_end = child_start + 1; - - if resolved_cells.len() <= c * child_start { - // Ensure the automatically chosen row actually exists. - resolved_cells.resize_with(c * (child_start + 1), || None); - } - } - - if is_header { - if child_start != 0 { - bail!( - child_span, - "header must start at the first row"; - hint: "remove any rows before the header" - ); - } - - header = Some(Header { - // Later on, we have to correct this number in case there - // is gutter. But only once all cells have been analyzed - // and the header has fully expanded in the fixup loop - // below. - end: child_end, - }); - } - - if is_footer { - // Only check if the footer is at the end later, once we know - // the final amount of rows. - footer = Some(( - child_end, - child_span, - Footer { - // Later on, we have to correct this number in case there - // is gutter, but only once all cells have been analyzed - // and the header's and footer's exact boundaries are - // known. That is because the gutter row immediately - // before the footer might not be included as part of - // the footer if it is contained within the header. - start: child_start, - }, - )); - } - - if is_header || is_footer { - let amount_hlines = pending_hlines.len(); - for (_, top_hline, has_auto_y) in pending_hlines - .get_mut( - first_index_of_top_hlines - ..first_index_of_non_top_hlines.min(amount_hlines), - ) - .unwrap_or(&mut []) - { - if *has_auto_y { - // Move this hline to the top of the child, as it was - // placed before the first automatically positioned cell - // and had an automatic index. - top_hline.index = child_start; - } - } - - // Next automatically positioned cell goes under this header. - // FIXME: Consider only doing this if the header has any fully - // automatically positioned cells. Otherwise, - // `resolve_cell_position` should be smart enough to skip - // upcoming headers. - // Additionally, consider that cells with just an 'x' override - // could end up going too far back and making previous - // non-header rows into header rows (maybe they should be - // placed at the first row that is fully empty or something). - // Nothing we can do when both 'x' and 'y' were overridden, of - // course. - // None of the above are concerns for now, as headers must - // start at the first row. - auto_index = auto_index.max(c * child_end); - } - } - - // If the user specified cells occupying less rows than the given rows, - // we shall expand the grid so that it has at least the given amount of - // rows. - let Some(expected_total_cells) = c.checked_mul(tracks.y.len()) else { - bail!(span, "too many rows were specified"); - }; - let missing_cells = expected_total_cells.saturating_sub(resolved_cells.len()); - - // Fixup phase (final step in cell grid generation): - // 1. Replace absent entries by resolved empty cells, and produce a - // vector of 'Entry' from 'Option'. - // 2. Add enough empty cells to the end of the grid such that it has at - // least the given amount of rows. - // 3. If any cells were added to the header's rows after the header's - // creation, ensure the header expands enough to accommodate them - // across all of their spanned rows. Same for the footer. - // 4. If any cells before the footer try to span it, error. - let resolved_cells = resolved_cells - .into_iter() - .chain(std::iter::repeat_with(|| None).take(missing_cells)) - .enumerate() - .map(|(i, cell)| { - if let Some(cell) = cell { - if let Some(parent_cell) = cell.as_cell() { - if let Some(header) = &mut header - { - let y = i / c; - if y < header.end { - // Ensure the header expands enough such that - // all cells inside it, even those added later, - // are fully contained within the header. - // FIXME: check if start < y < end when start can - // be != 0. - // FIXME: when start can be != 0, decide what - // happens when a cell after the header placed - // above it tries to span the header (either - // error or expand upwards). - header.end = header.end.max(y + parent_cell.rowspan.get()); - } - } - - if let Some((end, footer_span, footer)) = &mut footer { - let x = i % c; - let y = i / c; - let cell_end = y + parent_cell.rowspan.get(); - if y < footer.start && cell_end > footer.start { - // Don't allow a cell before the footer to span - // it. Surely, we could move the footer to - // start at where this cell starts, so this is - // more of a design choice, as it's unlikely - // for the user to intentionally include a cell - // before the footer spanning it but not - // being repeated with it. - bail!( - *footer_span, - "footer would conflict with a cell placed before it at column {x} row {y}"; - hint: "try reducing that cell's rowspan or moving the footer" - ); - } - if y >= footer.start && y < *end { - // Expand the footer to include all rows - // spanned by this cell, as it is inside the - // footer. - *end = (*end).max(cell_end); - } - } - } - - Ok(cell) - } else { - let x = i % c; - let y = i / c; - - // Ensure all absent entries are affected by show rules and - // grid styling by turning them into resolved empty cells. - let new_cell = T::default().resolve_cell( - x, - y, - &fill.resolve(engine, styles, x, y)?, - align.resolve(engine, styles, x, y)?, - inset.resolve(engine, styles, x, y)?, - stroke.resolve(engine, styles, x, y)?, - resolve_breakable(y, 1), - locator.next(&()), - styles, - ); - Ok(Entry::Cell(new_cell)) - } - }) - .collect::>>()?; - - // Populate the final lists of lines. - // For each line type (horizontal or vertical), we keep a vector for - // every group of lines with the same index. - let mut vlines: Vec> = vec![]; - let mut hlines: Vec> = vec![]; - let row_amount = resolved_cells.len().div_ceil(c); - - for (line_span, line, _) in pending_hlines { - let y = line.index; - if y > row_amount { - bail!(line_span, "cannot place horizontal line at invalid row {y}"); - } - if y == row_amount && line.position == LinePosition::After { - bail!( - line_span, - "cannot place horizontal line at the 'bottom' position of the bottom border (y = {y})"; - hint: "set the line's position to 'top' or place it at a smaller 'y' index" - ); - } - let line = if line.position == LinePosition::After - && (!has_gutter || y + 1 == row_amount) - { - // Just place the line on top of the next row if - // there's no gutter and the line should be placed - // after the one with given index. - // - // Note that placing after the last row is also the same as - // just placing on the grid's bottom border, even with - // gutter. - Line { - index: y + 1, - position: LinePosition::Before, - ..line - } - } else { - line - }; - let y = line.index; - - if hlines.len() <= y { - hlines.resize_with(y + 1, Vec::new); - } - hlines[y].push(line); - } - - for (line_span, line) in pending_vlines { - let x = line.index; - if x > c { - bail!(line_span, "cannot place vertical line at invalid column {x}"); - } - if x == c && line.position == LinePosition::After { - bail!( - line_span, - "cannot place vertical line at the 'end' position of the end border (x = {c})"; - hint: "set the line's position to 'start' or place it at a smaller 'x' index" - ); - } - let line = - if line.position == LinePosition::After && (!has_gutter || x + 1 == c) { - // Just place the line before the next column if - // there's no gutter and the line should be placed - // after the one with given index. - // - // Note that placing after the last column is also the - // same as just placing on the grid's end border, even - // with gutter. - Line { - index: x + 1, - position: LinePosition::Before, - ..line - } - } else { - line - }; - let x = line.index; - - if vlines.len() <= x { - vlines.resize_with(x + 1, Vec::new); - } - vlines[x].push(line); - } - - let header = header - .map(|mut header| { - // Repeat the gutter below a header (hence why we don't - // subtract 1 from the gutter case). - // Don't do this if there are no rows under the header. - if has_gutter { - // - 'header.end' is always 'last y + 1'. The header stops - // before that row. - // - Therefore, '2 * header.end' will be 2 * (last y + 1), - // which is the adjusted index of the row before which the - // header stops, meaning it will still stop right before it - // even with gutter thanks to the multiplication below. - // - This means that it will span all rows up to - // '2 * (last y + 1) - 1 = 2 * last y + 1', which equates - // to the index of the gutter row right below the header, - // which is what we want (that gutter spacing should be - // repeated across pages to maintain uniformity). - header.end *= 2; - - // If the header occupies the entire grid, ensure we don't - // include an extra gutter row when it doesn't exist, since - // the last row of the header is at the very bottom, - // therefore '2 * last y + 1' is not a valid index. - let row_amount = (2 * row_amount).saturating_sub(1); - header.end = header.end.min(row_amount); - } - header - }) - .map(|header| { - if repeat_header { - Repeatable::Repeated(header) - } else { - Repeatable::NotRepeated(header) - } - }); - - let footer = footer - .map(|(footer_end, footer_span, mut footer)| { - if footer_end != row_amount { - bail!(footer_span, "footer must end at the last row"); - } - - let header_end = - header.as_ref().map(Repeatable::unwrap).map(|header| header.end); - - if has_gutter { - // Convert the footer's start index to post-gutter coordinates. - footer.start *= 2; - - // Include the gutter right before the footer, unless there is - // none, or the gutter is already included in the header (no - // rows between the header and the footer). - if header_end != Some(footer.start) { - footer.start = footer.start.saturating_sub(1); - } - } - - if header_end.is_some_and(|header_end| header_end > footer.start) { - bail!(footer_span, "header and footer must not have common rows"); - } - - Ok(footer) - }) - .transpose()? - .map(|footer| { - if repeat_footer { - Repeatable::Repeated(footer) - } else { - Repeatable::NotRepeated(footer) - } - }); - - Ok(Self::new_internal( - tracks, - gutter, - vlines, - hlines, - header, - footer, - resolved_cells, - )) - } - /// Generates the cell grid, given the tracks and resolved entries. pub fn new_internal( tracks: Axes<&[Sizing]>, @@ -1432,14 +671,14 @@ impl<'a> CellGrid<'a> { let mut rows = vec![]; // Number of content columns: Always at least one. - let c = tracks.x.len().max(1); + let num_cols = tracks.x.len().max(1); // Number of content rows: At least as many as given, but also at least // as many as needed to place each item. - let r = { + let num_rows = { let len = entries.len(); let given = tracks.y.len(); - let needed = len / c + (len % c).clamp(0, 1); + let needed = len / num_cols + (len % num_cols).clamp(0, 1); given.max(needed) }; @@ -1451,7 +690,7 @@ impl<'a> CellGrid<'a> { }; // Collect content and gutter columns. - for x in 0..c { + for x in 0..num_cols { cols.push(get_or(tracks.x, x, auto)); if has_gutter { cols.push(get_or(gutter.x, x, zero)); @@ -1459,7 +698,7 @@ impl<'a> CellGrid<'a> { } // Collect content and gutter rows. - for y in 0..r { + for y in 0..num_rows { rows.push(get_or(tracks.y, y, auto)); if has_gutter { rows.push(get_or(gutter.y, y, zero)); @@ -1615,25 +854,1140 @@ impl<'a> CellGrid<'a> { } } +/// Resolves and positions all cells in the grid before creating it. +/// Allows them to keep track of their final properties and positions +/// and adjust their fields accordingly. +/// Cells must implement Clone as they will be owned. Additionally, they +/// must implement Default in order to fill positions in the grid which +/// weren't explicitly specified by the user with empty cells. +#[allow(clippy::too_many_arguments)] +pub fn resolve_cellgrid<'a, 'x, T, C, I>( + tracks: Axes<&'a [Sizing]>, + gutter: Axes<&'a [Sizing]>, + locator: Locator<'x>, + children: C, + fill: &'a Celled>, + align: &'a Celled>, + inset: &'a Celled>>>, + stroke: &'a ResolvedCelled>>>>, + engine: &'a mut Engine, + styles: StyleChain<'a>, + span: Span, +) -> SourceResult> +where + T: ResolvableCell + Default, + I: Iterator>, + C: IntoIterator>, + C::IntoIter: ExactSizeIterator, +{ + CellGridResolver { + tracks, + gutter, + locator: locator.split(), + fill, + align, + inset, + stroke, + engine, + styles, + span, + } + .resolve(children) +} + +struct CellGridResolver<'a, 'b, 'x> { + tracks: Axes<&'a [Sizing]>, + gutter: Axes<&'a [Sizing]>, + locator: SplitLocator<'x>, + fill: &'a Celled>, + align: &'a Celled>, + inset: &'a Celled>>>, + stroke: &'a ResolvedCelled>>>>, + engine: &'a mut Engine<'b>, + styles: StyleChain<'a>, + span: Span, +} + +#[derive(Debug, Clone, Copy)] +enum RowGroupKind { + Header, + Footer, +} + +impl RowGroupKind { + fn name(self) -> &'static str { + match self { + Self::Header => "header", + Self::Footer => "footer", + } + } +} + +struct RowGroupData { + /// The range of rows of cells inside this grid row group. The + /// first and last rows are guaranteed to have cells (an exception + /// is made when there is gutter, in which case the group range may + /// be expanded to include an additional gutter row when there is a + /// repeatable header or footer). This is `None` until the first + /// cell of the row group is placed, then it is continually adjusted + /// to fit the cells inside the row group. + /// + /// This stays as `None` for fully empty headers and footers. + range: Option>, + span: Span, + kind: RowGroupKind, + + /// Start of the range of indices of hlines at the top of the row group. + /// This is always the first index after the last hline before we started + /// building the row group - any upcoming hlines would appear at least at + /// this index. + /// + /// These hlines were auto-positioned and appeared before any auto-pos + /// cells, so they will appear at the first possible row (above the + /// first row spanned by the row group). + top_hlines_start: usize, + + /// End of the range of indices of hlines at the top of the row group. + /// + /// This starts as `None`, meaning that, if we stop the loop before we find + /// any auto-pos cells, all auto-pos hlines after the last hline (after the + /// index `top_hlines_start`) should be moved to the top of the row group. + /// + /// It becomes `Some(index of last hline at the top)` when an auto-pos cell + /// is found, as auto-pos hlines after any auto-pos cells appear below + /// them, not at the top of the row group. + top_hlines_end: Option, +} + +impl<'x> CellGridResolver<'_, '_, 'x> { + fn resolve(mut self, children: C) -> SourceResult> + where + T: ResolvableCell + Default, + I: Iterator>, + C: IntoIterator>, + C::IntoIter: ExactSizeIterator, + { + // Number of content columns: Always at least one. + let columns = self.tracks.x.len().max(1); + + // Lists of lines. + // Horizontal lines are only pushed later to be able to check for row + // validity, since the amount of rows isn't known until all items were + // analyzed in the for loop below. + // We keep their spans so we can report errors later. + // The additional boolean indicates whether the hline had an automatic + // 'y' index, and is used to change the index of hlines at the top of a + // header or footer. + let mut pending_hlines: Vec<(Span, Line, bool)> = vec![]; + + // For consistency, only push vertical lines later as well. + let mut pending_vlines: Vec<(Span, Line)> = vec![]; + let has_gutter = self.gutter.any(|tracks| !tracks.is_empty()); + + let mut header: Option
= None; + let mut repeat_header = false; + + // Stores where the footer is supposed to end, its span, and the + // actual footer structure. + let mut footer: Option<(usize, Span, Footer)> = None; + let mut repeat_footer = false; + + // We can't just use the cell's index in the 'cells' vector to + // determine its automatic position, since cells could have arbitrary + // positions, so the position of a cell in 'cells' can differ from its + // final position in 'resolved_cells' (see below). + // Therefore, we use a counter, 'auto_index', to determine the position + // of the next cell with (x: auto, y: auto). It is only stepped when + // a cell with (x: auto, y: auto), usually the vast majority, is found. + // + // Note that a separate counter ('local_auto_index') is used within + // headers and footers, as explained above its definition. Outside of + // those (when the table child being processed is a single cell), + // 'local_auto_index' will simply be an alias for 'auto_index', which + // will be updated after that cell is placed, if it is an + // automatically-positioned cell. + let mut auto_index: usize = 0; + + // We have to rebuild the grid to account for fixed cell positions. + // + // Create at least 'children.len()' positions, since there could be at + // least 'children.len()' cells (if no explicit lines were specified), + // even though some of them might be placed in fixed positions and thus + // cause the grid to expand. + // + // Additionally, make sure we allocate up to the next multiple of + // 'columns', since each row will have 'columns' cells, even if the + // last few cells weren't explicitly specified by the user. + let children = children.into_iter(); + let Some(child_count) = children.len().checked_next_multiple_of(columns) else { + bail!(self.span, "too many cells or lines were given") + }; + let mut resolved_cells: Vec> = Vec::with_capacity(child_count); + for child in children { + self.resolve_grid_child( + columns, + &mut pending_hlines, + &mut pending_vlines, + &mut header, + &mut repeat_header, + &mut footer, + &mut repeat_footer, + &mut auto_index, + &mut resolved_cells, + child, + )?; + } + + let resolved_cells = self.fixup_cells::(resolved_cells, columns)?; + + let row_amount = resolved_cells.len().div_ceil(columns); + let (hlines, vlines) = self.collect_lines( + pending_hlines, + pending_vlines, + has_gutter, + columns, + row_amount, + )?; + + let (header, footer) = self.finalize_headers_and_footers( + has_gutter, + header, + repeat_header, + footer, + repeat_footer, + row_amount, + )?; + + Ok(CellGrid::new_internal( + self.tracks, + self.gutter, + vlines, + hlines, + header, + footer, + resolved_cells, + )) + } + + /// Resolve a grid child, which can be a header, a footer (both of which + /// are row groups, and thus contain multiple grid items inside them), or + /// a grid item - a cell, an hline or a vline. + /// + /// This process consists of placing the child and any sub-items into + /// appropriate positions in the resolved grid. This is mostly relevant for + /// items without fixed positions, such that they must be placed after the + /// previous one, perhaps skipping existing cells along the way. + #[allow(clippy::too_many_arguments)] + fn resolve_grid_child( + &mut self, + columns: usize, + pending_hlines: &mut Vec<(Span, Line, bool)>, + pending_vlines: &mut Vec<(Span, Line)>, + header: &mut Option
, + repeat_header: &mut bool, + footer: &mut Option<(usize, Span, Footer)>, + repeat_footer: &mut bool, + auto_index: &mut usize, + resolved_cells: &mut Vec>>, + child: ResolvableGridChild, + ) -> SourceResult<()> + where + T: ResolvableCell + Default, + I: Iterator>, + { + // Data for the row group in this iteration. + // + // Note that cells outside headers and footers are grid children + // with a single cell inside, and thus not considered row groups, + // in which case this variable remains 'None'. + let mut row_group_data: Option = None; + + // The normal auto index should only be stepped (upon placing an + // automatically-positioned cell, to indicate the position of the + // next) outside of headers or footers, in which case the auto + // index will be updated with the local auto index. Inside headers + // and footers, however, cells can only start after the first empty + // row (as determined by 'first_available_row' below), meaning that + // the next automatically-positioned cell will be in a different + // position than it would usually be if it would be in a non-empty + // row, so we must step a local index inside headers and footers + // instead, and use a separate counter outside them. + let mut local_auto_index = *auto_index; + + // The first row in which this table group can fit. + // + // Within headers and footers, this will correspond to the first + // fully empty row available in the grid. This is because headers + // and footers always occupy entire rows, so they cannot occupy + // a non-empty row. + let mut first_available_row = 0; + + let (header_footer_items, simple_item) = match child { + ResolvableGridChild::Header { repeat, span, items, .. } => { + if header.is_some() { + bail!(span, "cannot have more than one header"); + } + + row_group_data = Some(RowGroupData { + range: None, + span, + kind: RowGroupKind::Header, + top_hlines_start: pending_hlines.len(), + top_hlines_end: None, + }); + + *repeat_header = repeat; + + first_available_row = + find_next_empty_row(resolved_cells, local_auto_index, columns); + + // If any cell in the header is automatically positioned, + // have it skip to the next empty row. This is to avoid + // having a header after a partially filled row just add + // cells to that row instead of starting a new one. + // + // Note that the first fully empty row is always after the + // latest auto-position cell, since each auto-position cell + // always occupies the first available position after the + // previous one. Therefore, this will be >= auto_index. + local_auto_index = first_available_row * columns; + + (Some(items), None) + } + ResolvableGridChild::Footer { repeat, span, items, .. } => { + if footer.is_some() { + bail!(span, "cannot have more than one footer"); + } + + row_group_data = Some(RowGroupData { + range: None, + span, + kind: RowGroupKind::Footer, + top_hlines_start: pending_hlines.len(), + top_hlines_end: None, + }); + + *repeat_footer = repeat; + + first_available_row = + find_next_empty_row(resolved_cells, local_auto_index, columns); + + local_auto_index = first_available_row * columns; + + (Some(items), None) + } + ResolvableGridChild::Item(item) => (None, Some(item)), + }; + + let items = header_footer_items.into_iter().flatten().chain(simple_item); + for item in items { + let cell = match item { + ResolvableGridItem::HLine { y, start, end, stroke, span, position } => { + let has_auto_y = y.is_auto(); + let y = y.unwrap_or_else(|| { + // Avoid placing the hline inside consecutive + // rowspans occupying all columns, as it'd just + // disappear, at least when there's no column + // gutter. + skip_auto_index_through_fully_merged_rows( + resolved_cells, + &mut local_auto_index, + columns, + ); + + // When no 'y' is specified for the hline, we place + // it under the latest automatically positioned + // cell. + // The current value of the auto index is always + // the index of the latest automatically positioned + // cell placed plus one (that's what we do in + // 'resolve_cell_position'), so we subtract 1 to + // get that cell's index, and place the hline below + // its row. The exception is when the auto_index is + // 0, meaning no automatically positioned cell was + // placed yet. In that case, we place the hline at + // the top of the table. + // + // Exceptionally, the hline will be placed before + // the minimum auto index if the current auto index + // from previous iterations is smaller than the + // minimum it should have for the current grid + // child. Effectively, this means that a hline at + // the start of a header will always appear above + // that header's first row. Similarly for footers. + local_auto_index + .checked_sub(1) + .map_or(0, |last_auto_index| last_auto_index / columns + 1) + }); + if end.is_some_and(|end| end.get() < start) { + bail!(span, "line cannot end before it starts"); + } + let line = Line { index: y, start, end, stroke, position }; + + // Since the amount of rows is dynamic, delay placing + // hlines until after all cells were placed so we can + // properly verify if they are valid. Note that we + // can't place hlines even if we already know they + // would be in a valid row, since it's possible that we + // pushed pending hlines in the same row as this one in + // previous iterations, and we need to ensure that + // hlines from previous iterations are pushed to the + // final vector of hlines first - the order of hlines + // must be kept, as this matters when determining which + // one "wins" in case of conflict. Pushing the current + // hline before we push pending hlines later would + // change their order! + pending_hlines.push((span, line, has_auto_y)); + continue; + } + ResolvableGridItem::VLine { x, start, end, stroke, span, position } => { + let x = x.unwrap_or_else(|| { + // When no 'x' is specified for the vline, we place + // it after the latest automatically positioned + // cell. + // The current value of the auto index is always + // the index of the latest automatically positioned + // cell placed plus one (that's what we do in + // 'resolve_cell_position'), so we subtract 1 to + // get that cell's index, and place the vline after + // its column. The exception is when the auto_index + // is 0, meaning no automatically positioned cell + // was placed yet. In that case, we place the vline + // to the left of the table. + // + // Exceptionally, a vline is also placed to the + // left of the table when specified at the start + // of a row group, such as a header or footer, that + // is, when no automatically-positioned cells have + // been specified for that group yet. + // For example, this means that a vline at + // the beginning of a header will be placed to its + // left rather than after the previous + // automatically positioned cell. Same for footers. + local_auto_index + .checked_sub(1) + .filter(|_| local_auto_index > first_available_row * columns) + .map_or(0, |last_auto_index| last_auto_index % columns + 1) + }); + if end.is_some_and(|end| end.get() < start) { + bail!(span, "line cannot end before it starts"); + } + let line = Line { index: x, start, end, stroke, position }; + + // For consistency with hlines, we only push vlines to + // the final vector of vlines after processing every + // cell. + pending_vlines.push((span, line)); + continue; + } + ResolvableGridItem::Cell(cell) => cell, + }; + let cell_span = cell.span(); + let colspan = cell.colspan(self.styles).get(); + let rowspan = cell.rowspan(self.styles).get(); + // Let's calculate the cell's final position based on its + // requested position. + let resolved_index = { + let cell_x = cell.x(self.styles); + let cell_y = cell.y(self.styles); + resolve_cell_position( + cell_x, + cell_y, + colspan, + rowspan, + header.as_ref(), + footer.as_ref(), + resolved_cells, + &mut local_auto_index, + first_available_row, + columns, + row_group_data.is_some(), + ) + .at(cell_span)? + }; + let x = resolved_index % columns; + let y = resolved_index / columns; + + if colspan > columns - x { + bail!( + cell_span, + "cell's colspan would cause it to exceed the available column(s)"; + hint: "try placing the cell in another position or reducing its colspan" + ) + } + + let Some(largest_index) = columns + .checked_mul(rowspan - 1) + .and_then(|full_rowspan_offset| { + resolved_index.checked_add(full_rowspan_offset) + }) + .and_then(|last_row_pos| last_row_pos.checked_add(colspan - 1)) + else { + bail!( + cell_span, + "cell would span an exceedingly large position"; + hint: "try reducing the cell's rowspan or colspan" + ) + }; + + // Cell's header or footer must expand to include the cell's + // occupied positions, if possible. + if let Some(RowGroupData { + range: group_range, kind, top_hlines_end, .. + }) = &mut row_group_data + { + *group_range = Some( + expand_row_group( + resolved_cells, + group_range.as_ref(), + *kind, + first_available_row, + y, + rowspan, + columns, + ) + .at(cell_span)?, + ); + + if top_hlines_end.is_none() + && local_auto_index > first_available_row * columns + { + // Auto index was moved, so upcoming auto-pos hlines should + // no longer appear at the top. + *top_hlines_end = Some(pending_hlines.len()); + } + } + + // Let's resolve the cell so it can determine its own fields + // based on its final position. + let cell = self.resolve_cell(cell, x, y, rowspan, cell_span)?; + + if largest_index >= resolved_cells.len() { + // Ensure the length of the vector of resolved cells is + // always a multiple of 'columns' by pushing full rows every + // time. Here, we add enough absent positions (later + // converted to empty cells) to ensure the last row in the + // new vector length is completely filled. This is + // necessary so that those positions, even if not + // explicitly used at the end, are eventually susceptible + // to show rules and receive grid styling, as they will be + // resolved as empty cells in a second loop below. + let Some(new_len) = largest_index + .checked_add(1) + .and_then(|new_len| new_len.checked_next_multiple_of(columns)) + else { + bail!(cell_span, "cell position too large") + }; + + // Here, the cell needs to be placed in a position which + // doesn't exist yet in the grid (out of bounds). We will + // add enough absent positions for this to be possible. + // They must be absent as no cells actually occupy them + // (they can be overridden later); however, if no cells + // occupy them as we finish building the grid, then such + // positions will be replaced by empty cells. + resolved_cells.resize_with(new_len, || None); + } + + // The vector is large enough to contain the cell, so we can + // just index it directly to access the position it will be + // placed in. However, we still need to ensure we won't try to + // place a cell where there already is one. + let slot = &mut resolved_cells[resolved_index]; + if slot.is_some() { + bail!( + cell_span, + "attempted to place a second cell at column {x}, row {y}"; + hint: "try specifying your cells in a different order" + ); + } + + *slot = Some(Entry::Cell(cell)); + + // Now, if the cell spans more than one row or column, we fill + // the spanned positions in the grid with Entry::Merged + // pointing to the original cell as its parent. + for rowspan_offset in 0..rowspan { + let spanned_y = y + rowspan_offset; + let first_row_index = resolved_index + columns * rowspan_offset; + for (colspan_offset, slot) in + resolved_cells[first_row_index..][..colspan].iter_mut().enumerate() + { + let spanned_x = x + colspan_offset; + if spanned_x == x && spanned_y == y { + // This is the parent cell. + continue; + } + if slot.is_some() { + bail!( + cell_span, + "cell would span a previously placed cell at column {spanned_x}, row {spanned_y}"; + hint: "try specifying your cells in a different order or reducing the cell's rowspan or colspan" + ) + } + *slot = Some(Entry::Merged { parent: resolved_index }); + } + } + } + + if let Some(row_group) = row_group_data { + let group_range = match row_group.range { + Some(group_range) => group_range, + + None => { + // Empty header/footer: consider the header/footer to be + // at the next empty row after the latest auto index. + local_auto_index = first_available_row * columns; + let group_start = first_available_row; + let group_end = group_start + 1; + + if resolved_cells.len() <= columns * group_start { + // Ensure the automatically chosen row actually exists. + resolved_cells.resize_with(columns * (group_start + 1), || None); + } + + // Even though this header or footer is fully empty, we add one + // default cell to maintain the invariant that each header and + // footer has at least one 'Some(...)' cell at its first row + // and at least one at its last row (here they are the same + // row, of course). This invariant is important to ensure + // 'find_next_empty_row' will skip through any existing headers + // and footers without having to loop through them each time. + // Cells themselves, unfortunately, still have to. + assert!(resolved_cells[local_auto_index].is_none()); + resolved_cells[local_auto_index] = + Some(Entry::Cell(self.resolve_cell( + T::default(), + 0, + first_available_row, + 1, + Span::detached(), + )?)); + + group_start..group_end + } + }; + + let top_hlines_end = row_group.top_hlines_end.unwrap_or(pending_hlines.len()); + for (_, top_hline, has_auto_y) in pending_hlines + .get_mut(row_group.top_hlines_start..top_hlines_end) + .unwrap_or(&mut []) + { + if *has_auto_y { + // Move this hline to the top of the child, as it was + // placed before the first automatically positioned cell + // and had an automatic index. + top_hline.index = group_range.start; + } + } + + match row_group.kind { + RowGroupKind::Header => { + if group_range.start != 0 { + bail!( + row_group.span, + "header must start at the first row"; + hint: "remove any rows before the header" + ); + } + + *header = Some(Header { + // Later on, we have to correct this number in case there + // is gutter. But only once all cells have been analyzed + // and the header has fully expanded in the fixup loop + // below. + end: group_range.end, + }); + } + + 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, + }, + )); + } + } + } else { + // The child was a single cell outside headers or footers. + // Therefore, 'local_auto_index' for this table child was + // simply an alias for 'auto_index', so we update it as needed. + *auto_index = local_auto_index; + } + + Ok(()) + } + + /// Fixup phase (final step in cell grid generation): + /// + /// 1. Replace absent entries by resolved empty cells, producing a vector + /// of `Entry` from `Option`. + /// + /// 2. Add enough empty cells to the end of the grid such that it has at + /// least the given amount of rows (must be a multiple of `columns`, + /// and all rows before the last cell must have cells, empty or not, + /// even if the user didn't specify those cells). + /// + /// That is necessary, for example, to ensure even unspecified cells + /// can be affected by show rules and grid-wide styling. + fn fixup_cells( + &mut self, + resolved_cells: Vec>>, + columns: usize, + ) -> SourceResult>> + where + T: ResolvableCell + Default, + { + let Some(expected_total_cells) = columns.checked_mul(self.tracks.y.len()) else { + bail!(self.span, "too many rows were specified"); + }; + let missing_cells = expected_total_cells.saturating_sub(resolved_cells.len()); + + resolved_cells + .into_iter() + .chain(std::iter::repeat_with(|| None).take(missing_cells)) + .enumerate() + .map(|(i, cell)| { + if let Some(cell) = cell { + Ok(cell) + } else { + let x = i % columns; + let y = i / columns; + + Ok(Entry::Cell(self.resolve_cell( + T::default(), + x, + y, + 1, + Span::detached(), + )?)) + } + }) + .collect::>>() + } + + /// Takes the list of pending lines and evaluates a final list of hlines + /// and vlines (in that order in the returned tuple), detecting invalid + /// line positions in the process. + /// + /// For each line type (horizontal and vertical respectively), returns a + /// vector containing one inner vector for every group of lines with the + /// same index. + /// + /// For example, an hline above the second row (y = 1) is inside the inner + /// vector at position 1 of the first vector (hlines) returned by this + /// function. + #[allow(clippy::type_complexity)] + fn collect_lines( + &self, + pending_hlines: Vec<(Span, Line, bool)>, + pending_vlines: Vec<(Span, Line)>, + has_gutter: bool, + columns: usize, + row_amount: usize, + ) -> SourceResult<(Vec>, Vec>)> { + let mut hlines: Vec> = vec![]; + let mut vlines: Vec> = vec![]; + + for (line_span, line, _) in pending_hlines { + let y = line.index; + if y > row_amount { + bail!(line_span, "cannot place horizontal line at invalid row {y}"); + } + if y == row_amount && line.position == LinePosition::After { + bail!( + line_span, + "cannot place horizontal line at the 'bottom' position of the bottom border (y = {y})"; + hint: "set the line's position to 'top' or place it at a smaller 'y' index" + ); + } + let line = if line.position == LinePosition::After + && (!has_gutter || y + 1 == row_amount) + { + // Just place the line on top of the next row if + // there's no gutter and the line should be placed + // after the one with given index. + // + // Note that placing after the last row is also the same as + // just placing on the grid's bottom border, even with + // gutter. + Line { + index: y + 1, + position: LinePosition::Before, + ..line + } + } else { + line + }; + let y = line.index; + + if hlines.len() <= y { + hlines.resize_with(y + 1, Vec::new); + } + hlines[y].push(line); + } + + for (line_span, line) in pending_vlines { + let x = line.index; + if x > columns { + bail!(line_span, "cannot place vertical line at invalid column {x}"); + } + if x == columns && line.position == LinePosition::After { + bail!( + line_span, + "cannot place vertical line at the 'end' position of the end border (x = {columns})"; + hint: "set the line's position to 'start' or place it at a smaller 'x' index" + ); + } + let line = if line.position == LinePosition::After + && (!has_gutter || x + 1 == columns) + { + // Just place the line before the next column if + // there's no gutter and the line should be placed + // after the one with given index. + // + // Note that placing after the last column is also the + // same as just placing on the grid's end border, even + // with gutter. + Line { + index: x + 1, + position: LinePosition::Before, + ..line + } + } else { + line + }; + let x = line.index; + + if vlines.len() <= x { + vlines.resize_with(x + 1, Vec::new); + } + vlines[x].push(line); + } + + Ok((hlines, vlines)) + } + + /// Generate the final headers and footers: + /// + /// 1. Convert gutter-ignorant to gutter-aware indices if necessary; + /// 2. Expand the header downwards (or footer upwards) to also include + /// 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, + header: Option
, + repeat_header: bool, + footer: Option<(usize, Span, Footer)>, + repeat_footer: bool, + row_amount: usize, + ) -> SourceResult<(Option>, Option>)> { + let header = header + .map(|mut header| { + // Repeat the gutter below a header (hence why we don't + // subtract 1 from the gutter case). + // Don't do this if there are no rows under the header. + if has_gutter { + // - 'header.end' is always 'last y + 1'. The header stops + // before that row. + // - Therefore, '2 * header.end' will be 2 * (last y + 1), + // which is the adjusted index of the row before which the + // header stops, meaning it will still stop right before it + // even with gutter thanks to the multiplication below. + // - This means that it will span all rows up to + // '2 * (last y + 1) - 1 = 2 * last y + 1', which equates + // to the index of the gutter row right below the header, + // which is what we want (that gutter spacing should be + // repeated across pages to maintain uniformity). + header.end *= 2; + + // If the header occupies the entire grid, ensure we don't + // include an extra gutter row when it doesn't exist, since + // the last row of the header is at the very bottom, + // therefore '2 * last y + 1' is not a valid index. + let row_amount = (2 * row_amount).saturating_sub(1); + header.end = header.end.min(row_amount); + } + header + }) + .map(|header| { + if repeat_header { + Repeatable::Repeated(header) + } else { + Repeatable::NotRepeated(header) + } + }); + + let footer = footer + .map(|(footer_end, footer_span, mut footer)| { + if footer_end != row_amount { + bail!(footer_span, "footer must end at the last row"); + } + + let header_end = + header.as_ref().map(Repeatable::unwrap).map(|header| header.end); + + if has_gutter { + // Convert the footer's start index to post-gutter coordinates. + footer.start *= 2; + + // Include the gutter right before the footer, unless there is + // none, or the gutter is already included in the header (no + // rows between the header and the footer). + if header_end != Some(footer.start) { + footer.start = footer.start.saturating_sub(1); + } + } + + Ok(footer) + }) + .transpose()? + .map(|footer| { + if repeat_footer { + Repeatable::Repeated(footer) + } else { + Repeatable::NotRepeated(footer) + } + }); + + Ok((header, footer)) + } + + /// Resolves the cell's fields based on grid-wide properties. + fn resolve_cell( + &mut self, + cell: T, + x: usize, + y: usize, + rowspan: usize, + cell_span: Span, + ) -> SourceResult> + where + T: ResolvableCell + Default, + { + // Resolve the breakability of a cell. Cells that span at least one + // auto-sized row or gutter are considered breakable. + let breakable = { + let auto = Sizing::Auto; + let zero = Sizing::Rel(Rel::zero()); + self.tracks + .y + .iter() + .chain(std::iter::repeat(self.tracks.y.last().unwrap_or(&auto))) + .skip(y) + .take(rowspan) + .any(|row| row == &Sizing::Auto) + || self + .gutter + .y + .iter() + .chain(std::iter::repeat(self.gutter.y.last().unwrap_or(&zero))) + .skip(y) + .take(rowspan - 1) + .any(|row_gutter| row_gutter == &Sizing::Auto) + }; + + Ok(cell.resolve_cell( + x, + y, + &self.fill.resolve(self.engine, self.styles, x, y)?, + self.align.resolve(self.engine, self.styles, x, y)?, + self.inset.resolve(self.engine, self.styles, x, y)?, + self.stroke.resolve(self.engine, self.styles, x, y)?, + breakable, + self.locator.next(&cell_span), + self.styles, + )) + } +} + +/// Given the existing range of a row group (header or footer), tries to expand +/// it to fit the new cell placed inside it. If the newly-expanded row group +/// would conflict with existing cells or other row groups, an error is +/// returned. Otherwise, the new `start..end` range of rows in the row group is +/// returned. +fn expand_row_group( + resolved_cells: &[Option>], + group_range: Option<&Range>, + group_kind: RowGroupKind, + first_available_row: usize, + cell_y: usize, + rowspan: usize, + columns: usize, +) -> HintedStrResult> { + // Ensure each cell in a header or footer is fully contained within it by + // expanding the header or footer towards this new cell. + let (new_group_start, new_group_end) = group_range + .map_or((cell_y, cell_y + rowspan), |r| { + (r.start.min(cell_y), r.end.max(cell_y + rowspan)) + }); + + // This check might be unnecessary with the loop below, but let's keep it + // here for full correctness. + // + // Quickly detect the case: + // y = 0 => occupied + // y = 1 => empty + // y = 2 => header + // and header tries to expand to y = 0 - invalid, as + // 'y = 1' is the earliest row it can occupy. + if new_group_start < first_available_row { + bail!( + "cell would cause {} to expand to non-empty row {}", + group_kind.name(), + first_available_row.saturating_sub(1); + hint: "try moving its cells to available rows" + ); + } + + let new_rows = + group_range.map_or((new_group_start..new_group_end).chain(0..0), |r| { + // NOTE: 'r.end' is one row AFTER the row group's last row, so it + // makes sense to check it if 'new_group_end > r.end', that is, if + // the row group is going to expand. It is NOT a duplicate check, + // as we hadn't checked it before (in a previous run, it was + // 'new_group_end' at the exclusive end of the range)! + // + // NOTE: To keep types the same, we have to always return + // '(range).chain(range)', which justifies chaining an empty + // range above. + (new_group_start..r.start).chain(r.end..new_group_end) + }); + + // The check above isn't enough, however, even when the header is expanding + // upwards, as it might expand upwards towards an occupied row after the + // first empty row, e.g. + // + // y = 0 => occupied + // y = 1 => empty (first_available_row = 1) + // y = 2 => occupied + // y = 3 => header + // + // Here, we should bail if the header tries to expand upwards, regardless + // of the fact that the conflicting row (y = 2) comes after the first + // available row. + // + // Note that expanding upwards is only possible when row-positioned cells + // are specified, in one of the following cases: + // + // 1. We place e.g. 'table.cell(y: 3)' followed by 'table.cell(y: 2)' + // (earlier row => upwards); + // + // 2. We place e.g. 'table.cell(y: 3)' followed by '[a]' (auto-pos cell + // favors 'first_available_row', so the header tries to expand upwards to + // place the cell at 'y = 1' and conflicts at 'y = 2') or + // 'table.cell(x: 1)' (same deal). + // + // Of course, we also need to check for downward expansion as usual as + // there could be a non-empty row below the header, but the upward case is + // highlighted as it was checked separately before (and also to explain + // what kind of situation we are preventing with this check). + // + // Note that simply checking for non-empty rows like below not only + // prevents conflicts with top-level cells (outside of headers and + // footers), but also prevents conflicts with other headers or footers, + // since we have an invariant that even empty headers and footers must + // contain at least one 'Some(...)' position in 'resolved_cells'. More + // precisely, each header and footer has at least one 'Some(...)' cell at + // 'group_range.start' and at 'group_range.end - 1' - non-empty headers and + // footers don't span any unnecessary rows. Therefore, we don't have to + // loop over headers and footers, only check if the new rows are empty. + for new_y in new_rows { + if let Some(new_row @ [_non_empty, ..]) = resolved_cells + .get(new_y * columns..) + .map(|cells| &cells[..columns.min(cells.len())]) + { + if new_row.iter().any(Option::is_some) { + bail!( + "cell would cause {} to expand to non-empty row {new_y}", + group_kind.name(); + hint: "try moving its cells to available rows", + ) + } + } else { + // Received 'None' or an empty slice, so we are expanding the + // header or footer into new rows, which is always valid and cannot + // conflict with existing cells. (Note that we only resize + // 'resolved_cells' after this function is called, so, if this + // header or footer is at the bottom of the table so far, this loop + // will end quite early, regardless of where this cell was placed + // or of its rowspan value.) + break; + } + } + + Ok(new_group_start..new_group_end) +} + +/// Check if a cell's fixed row would conflict with a header or footer. +fn check_for_conflicting_cell_row( + header: Option<&Header>, + footer: Option<&(usize, Span, Footer)>, + cell_y: usize, + rowspan: usize, +) -> HintedStrResult<()> { + if let Some(header) = header { + // TODO: check start (right now zero, always satisfied) + if cell_y < header.end { + bail!( + "cell would conflict with header spanning the same position"; + hint: "try moving the cell or the header" + ); + } + } + + if let Some((footer_end, _, footer)) = footer { + // NOTE: y + rowspan >, not >=, footer.start, to check if the rowspan + // enters the footer. For example, consider a rowspan of 1: if + // `y + 1 = footer.start` holds, that means `y < footer.start`, and it + // only occupies one row (`y`), so the cell is actually not in + // conflict. + 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" + ); + } + } + + Ok(()) +} + /// Given a cell's requested x and y, the vector with the resolved cell /// positions, the `auto_index` counter (determines the position of the next /// `(auto, auto)` cell) and the amount of columns in the grid, returns the /// final index of this cell in the vector of resolved cells. /// -/// The `start_new_row` parameter is used to ensure that, if this cell is -/// fully automatically positioned, it should start a new, empty row. This is -/// useful for headers and footers, which must start at their own rows, without -/// interference from previous cells. +/// The `first_available_row` parameter is used by headers and footers to +/// indicate the first empty row available. Any rows before those should +/// not be picked by cells with `auto` row positioning, since headers and +/// footers occupy entire rows, and may not conflict with cells outside them. #[allow(clippy::too_many_arguments)] fn resolve_cell_position( cell_x: Smart, cell_y: Smart, colspan: usize, rowspan: usize, + header: Option<&Header>, + footer: Option<&(usize, Span, Footer)>, resolved_cells: &[Option], auto_index: &mut usize, - start_new_row: &mut bool, + first_available_row: usize, columns: usize, + in_row_group: bool, ) -> HintedStrResult { // Translates a (x, y) position to the equivalent index in the final cell vector. // Errors if the position would be too large. @@ -1648,29 +2002,24 @@ fn resolve_cell_position( (Smart::Auto, Smart::Auto) => { // Let's find the first available position starting from the // automatic position counter, searching in row-major order. - let mut resolved_index = *auto_index; - if *start_new_row { - resolved_index = - find_next_empty_row(resolved_cells, resolved_index, columns); - - // Next cell won't have to start a new row if we just did that, - // in principle. - *start_new_row = false; - } else { - while let Some(Some(_)) = resolved_cells.get(resolved_index) { - // Skip any non-absent cell positions (`Some(None)`) to - // determine where this cell will be placed. An out of - // bounds position (thus `None`) is also a valid new - // position (only requires expanding the vector). - resolved_index += 1; - } - } + // Note that the counter ignores any cells with fixed positions, + // but automatically-positioned cells will avoid conflicts by + // simply skipping existing cells, headers and footers. + let resolved_index = find_next_available_position::( + header, + footer, + resolved_cells, + columns, + *auto_index, + )?; // Ensure the next cell with automatic position will be // placed after this one (maybe not immediately after). // // The calculation below also affects the position of the upcoming - // automatically-positioned lines. + // automatically-positioned lines, as they are placed below + // (horizontal lines) or to the right (vertical lines) of the cell + // that would be placed at 'auto_index'. *auto_index = if colspan == columns { // The cell occupies all columns, so no cells can be placed // after it until all of its rows have been spanned. @@ -1692,24 +2041,46 @@ fn resolve_cell_position( } if let Smart::Custom(cell_y) = cell_y { // Cell has chosen its exact position. + // + // Ensure it doesn't conflict with an existing header or + // 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(header, footer, cell_y, rowspan)?; + } + cell_index(cell_x, cell_y) } else { // Cell has only chosen its column. // Let's find the first row which has that column available. - let mut resolved_y = 0; - while let Some(Some(_)) = - resolved_cells.get(cell_index(cell_x, resolved_y)?) - { - // Try each row until either we reach an absent position - // (`Some(None)`) or an out of bounds position (`None`), - // in which case we'd create a new row to place this cell in. - resolved_y += 1; - } - cell_index(cell_x, resolved_y) + // If in a header or footer, start searching by the first empty + // row / the header or footer's first row (specified through + // 'first_available_row'). Otherwise, start searching at the + // first row. + let initial_index = cell_index(cell_x, first_available_row)?; + + // Try each row until either we reach an absent position at the + // requested column ('Some(None)') or an out of bounds position + // ('None'), in which case we'd create a new row to place this + // cell in. + find_next_available_position::( + header, + footer, + resolved_cells, + columns, + initial_index, + ) } } // Cell has only chosen its row, not its column. (Smart::Auto, Smart::Custom(cell_y)) => { + // Ensure it doesn't conflict with an existing header or + // 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(header, footer, cell_y, rowspan)?; + } + // Let's find the first column which has that row available. let first_row_pos = cell_index(0, cell_y)?; let last_row_pos = first_row_pos @@ -1736,14 +2107,73 @@ fn resolve_cell_position( } } -/// Computes the index of the first cell in the next empty row in the grid, -/// starting with the given initial index. +/// Finds the first available position after the initial index in the resolved +/// grid of cells. Skips any non-absent positions (positions which already +/// have cells specified by the user) as well as any headers and footers. +#[inline] +fn find_next_available_position( + header: Option<&Header>, + footer: Option<&(usize, Span, Footer)>, + resolved_cells: &[Option>], + columns: usize, + initial_index: usize, +) -> HintedStrResult { + let mut resolved_index = initial_index; + + loop { + if let Some(Some(_)) = resolved_cells.get(resolved_index) { + // Skip any non-absent cell positions (`Some(None)`) to + // determine where this cell will be placed. An out of + // bounds position (thus `None`) is also a valid new + // position (only requires expanding the vector). + if SKIP_ROWS { + // Skip one row at a time (cell chose its column, so we don't + // change it). + resolved_index = + resolved_index.checked_add(columns).ok_or_else(|| { + HintedString::from(eco_format!("cell position too large")) + })?; + } else { + // Ensure we don't run unnecessary checks in the hot path + // (for fully automatically-positioned cells). Memory usage + // would become impractically large before this overflows. + resolved_index += 1; + } + } else if let Some(header) = + header.filter(|header| resolved_index < header.end * columns) + { + // Skip header (can't place a cell inside it from outside it). + resolved_index = header.end * columns; + + if SKIP_ROWS { + // Ensure the cell's chosen column is kept after the + // header. + resolved_index += initial_index % columns; + } + } else if let Some((footer_end, _, _)) = footer.filter(|(end, _, footer)| { + resolved_index >= footer.start * columns && resolved_index < *end * columns + }) { + // Skip footer, for the same reason. + resolved_index = *footer_end * columns; + + if SKIP_ROWS { + resolved_index += initial_index % columns; + } + } else { + return Ok(resolved_index); + } + } +} + +/// Computes the `y` of the next available empty row, given the auto index as +/// an initial index for search, since we know that there are no empty rows +/// before automatically-positioned cells, as they are placed sequentially. fn find_next_empty_row( resolved_cells: &[Option], - initial_index: usize, + auto_index: usize, columns: usize, ) -> usize { - let mut resolved_index = initial_index.next_multiple_of(columns); + let mut resolved_index = auto_index.next_multiple_of(columns); while resolved_cells .get(resolved_index..resolved_index + columns) .is_some_and(|row| row.iter().any(Option::is_some)) @@ -1752,7 +2182,7 @@ fn find_next_empty_row( resolved_index += columns; } - resolved_index + resolved_index / columns } /// Fully merged rows under the cell of latest auto index indicate rowspans diff --git a/tests/ref/grid-footer-expand.png b/tests/ref/grid-footer-expand.png deleted file mode 100644 index 6b173b0da..000000000 Binary files a/tests/ref/grid-footer-expand.png and /dev/null differ diff --git a/tests/ref/grid-footer-moved-to-bottom-of-rowspans.png b/tests/ref/grid-footer-moved-to-bottom-of-rowspans.png new file mode 100644 index 000000000..d8a9c74f8 Binary files /dev/null and b/tests/ref/grid-footer-moved-to-bottom-of-rowspans.png differ diff --git a/tests/ref/grid-footer-top-hlines-with-only-row-pos-cell.png b/tests/ref/grid-footer-top-hlines-with-only-row-pos-cell.png new file mode 100644 index 000000000..f78e80c17 Binary files /dev/null and b/tests/ref/grid-footer-top-hlines-with-only-row-pos-cell.png differ diff --git a/tests/ref/grid-footer-top-hlines-with-row-and-auto-pos-cell.png b/tests/ref/grid-footer-top-hlines-with-row-and-auto-pos-cell.png new file mode 100644 index 000000000..fda05a3cd Binary files /dev/null and b/tests/ref/grid-footer-top-hlines-with-row-and-auto-pos-cell.png differ diff --git a/tests/ref/grid-header-cell-with-x.png b/tests/ref/grid-header-cell-with-x.png new file mode 100644 index 000000000..659826250 Binary files /dev/null and b/tests/ref/grid-header-cell-with-x.png differ diff --git a/tests/ref/grid-header-expand.png b/tests/ref/grid-header-expand.png deleted file mode 100644 index d0fbd72ed..000000000 Binary files a/tests/ref/grid-header-expand.png and /dev/null differ diff --git a/tests/ref/issue-5359-column-override-stays-inside-footer.png b/tests/ref/issue-5359-column-override-stays-inside-footer.png new file mode 100644 index 000000000..8339a4090 Binary files /dev/null and b/tests/ref/issue-5359-column-override-stays-inside-footer.png differ diff --git a/tests/suite/layout/grid/footers.typ b/tests/suite/layout/grid/footers.typ index edbb36fb1..f7f1deb0a 100644 --- a/tests/suite/layout/grid/footers.typ +++ b/tests/suite/layout/grid/footers.typ @@ -83,12 +83,55 @@ grid.cell(y: 1)[c], ) ---- grid-footer-expand --- -// Ensure footer properly expands +--- grid-footer-cell-with-x --- +#grid( + columns: 2, + 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] +) + +--- grid-footer-no-expand-with-col-and-row-pos-cell --- #grid( columns: 2, [a], [], [b], [], + fill: (_, y) => if calc.odd(y) { blue } else { red }, + inset: 5pt, + grid.cell(x: 1, y: 3, rowspan: 4)[b], + grid.cell(y: 2, rowspan: 2)[a], + grid.footer(), + // Error: 3-27 cell would conflict with footer spanning the same position + // Hint: 3-27 try reducing the cell's rowspan or moving the footer + grid.cell(x: 1, y: 7)[d], +) + +--- grid-footer-no-expand-with-row-pos-cell --- +#grid( + columns: 2, + [a], [], + [b], [], + fill: (_, y) => if calc.odd(y) { blue } else { red }, + inset: 5pt, + grid.cell(x: 1, y: 3, rowspan: 4)[b], + grid.cell(y: 2, rowspan: 2)[a], + grid.footer(), + // Error: 3-33 cell would conflict with footer spanning the same position + // Hint: 3-33 try reducing the cell's rowspan or moving the footer + grid.cell(y: 6, rowspan: 2)[d], +) + +--- grid-footer-moved-to-bottom-of-rowspans --- +#grid( + columns: 2, + [a], [], + [b], [], + stroke: red, + inset: 5pt, grid.cell(x: 1, y: 3, rowspan: 4)[b], grid.cell(y: 2, rowspan: 2)[a], grid.footer(), @@ -113,13 +156,13 @@ ) --- grid-footer-overlap --- -// Error: 4:3-4:19 footer would conflict with a cell placed before it at column 1 row 0 -// Hint: 4:3-4:19 try reducing that cell's rowspan or moving the footer #grid( columns: 2, grid.header(), - grid.footer([a]), - grid.cell(x: 1, y: 0, rowspan: 2)[a], + grid.footer(grid.cell(y: 2)[a]), + // Error: 3-39 cell would conflict with footer spanning the same position + // Hint: 3-39 try reducing the cell's rowspan or moving the footer + grid.cell(x: 1, y: 1, rowspan: 2)[a], ) --- grid-footer-multiple --- @@ -374,8 +417,8 @@ table.hline(stroke: red), table.vline(stroke: green), [b], + [c] ), - table.cell(x: 1, y: 3)[c] ) --- grid-footer-hline-and-vline-2 --- @@ -385,8 +428,8 @@ #table( columns: 3, inset: 1.5pt, - table.cell(y: 0)[a], table.footer( + table.cell(y: 0)[a], table.hline(stroke: red), table.hline(y: 1, stroke: aqua), table.cell(y: 0)[b], @@ -394,6 +437,38 @@ ) ) +--- grid-footer-top-hlines-with-only-row-pos-cell --- +// Top hlines should attach to the top of the footer. +#set page(margin: 2pt) +#set text(6pt) +#table( + columns: 3, + inset: 2.5pt, + table.footer( + table.hline(stroke: red), + table.vline(stroke: blue), + table.cell(x: 2, y: 2)[a], + table.hline(stroke: 3pt), + table.vline(stroke: 3pt), + ) +) + +--- grid-footer-top-hlines-with-row-and-auto-pos-cell --- +#set page(margin: 2pt) +#set text(6pt) +#table( + columns: 3, + inset: 2.5pt, + table.footer( + table.hline(stroke: red), + table.vline(stroke: blue), + table.cell(x: 2, y: 2)[a], + [b], + table.hline(stroke: 3pt), + table.vline(stroke: 3pt), + ) +) + --- grid-footer-below-rowspans --- // Footer should go below the rowspans. #set page(margin: 2pt) @@ -404,3 +479,71 @@ table.cell(rowspan: 2)[a], table.cell(rowspan: 2)[b], table.footer() ) + +--- grid-footer-row-pos-cell-inside-conflicts-with-row-before --- +#set page(margin: 2pt) +#set text(6pt) +#table( + columns: 3, + inset: 1.5pt, + table.cell(y: 0)[a], + table.footer( + table.hline(stroke: red), + table.hline(y: 1, stroke: aqua), + // Error: 5-24 cell would cause footer to expand to non-empty row 0 + // Hint: 5-24 try moving its cells to available rows + table.cell(y: 0)[b], + [c] + ) +) + +--- grid-footer-auto-pos-cell-inside-conflicts-with-row-after --- +#set page(margin: 2pt) +#set text(6pt) +#table( + columns: 2, + inset: 1.5pt, + table.cell(y: 1)[a], + table.footer( + [b], [c], + // Error: 6-7 cell would cause footer to expand to non-empty row 1 + // Hint: 6-7 try moving its cells to available rows + [d], + ), +) + +--- grid-footer-row-pos-cell-inside-conflicts-with-row-after --- +#set page(margin: 2pt) +#set text(6pt) +#table( + columns: 2, + inset: 1.5pt, + table.cell(y: 2)[a], + table.footer( + [b], [c], + // Error: 5-24 cell would cause footer to expand to non-empty row 2 + // Hint: 5-24 try moving its cells to available rows + table.cell(y: 3)[d], + ), +) + +--- grid-footer-conflicts-with-empty-header --- +#table( + columns: 2, + table.header(), + table.footer( + // Error: 5-24 cell would cause footer to expand to non-empty row 0 + // Hint: 5-24 try moving its cells to available rows + table.cell(y: 0)[a] + ), +) + +--- issue-5359-column-override-stays-inside-footer --- +#table( + columns: 3, + [Outside], + table.footer( + [A], table.cell(x: 1)[B], [C], + table.cell(x: 1)[D], + ), +) diff --git a/tests/suite/layout/grid/headers.typ b/tests/suite/layout/grid/headers.typ index cb2633765..229bce614 100644 --- a/tests/suite/layout/grid/headers.typ +++ b/tests/suite/layout/grid/headers.typ @@ -60,6 +60,16 @@ grid.cell(y: 2)[c] ) +--- grid-header-cell-with-x --- +#grid( + columns: 2, + stroke: black, + inset: 5pt, + grid.header(grid.cell(x: 0)[b1], grid.cell(x: 0)[b2]), + // This should skip the header + grid.cell(x: 1)[c] +) + --- grid-header-last-child --- // When the header is the last grid child, it shouldn't include the gutter row // after it, because there is none. @@ -273,8 +283,7 @@ ) #context count.display() ---- grid-header-expand --- -// Ensure header expands to fit cell placed in it after its declaration +--- grid-header-no-expand-with-col-and-row-pos-cell --- #set page(height: 10em) #table( columns: 2, @@ -282,9 +291,24 @@ [a], [b], [c], ), + // Error: 3-48 cell would conflict with header spanning the same position + // Hint: 3-48 try moving the cell or the header table.cell(x: 1, y: 1, rowspan: 2, lorem(80)) ) +--- grid-header-no-expand-with-row-pos-cell --- +#set page(height: 10em) +#table( + columns: 2, + table.header( + [a], [b], + [c], + ), + // Error: 3-42 cell would conflict with header spanning the same position + // Hint: 3-42 try moving the cell or the header + table.cell(y: 1, rowspan: 2, lorem(80)) +) + --- grid-nested-with-headers --- // Nested table with header should repeat both headers #set page(height: 10em) @@ -368,3 +392,81 @@ [b] ) ) + +--- grid-header-row-pos-cell-inside-conflicts-with-row-before --- +#set page(margin: 2pt) +#set text(6pt) +#table( + columns: 3, + inset: 1.5pt, + table.cell(y: 0)[a], + table.header( + table.hline(stroke: red), + table.hline(y: 1, stroke: aqua), + // Error: 5-24 cell would cause header to expand to non-empty row 0 + // Hint: 5-24 try moving its cells to available rows + table.cell(y: 0)[b], + [c] + ) +) + +--- grid-header-row-pos-cell-inside-conflicts-with-row-before-after-first-empty-row --- +#set page(margin: 2pt) +#set text(6pt) +#table( + columns: 3, + inset: 1.5pt, + // Rows: Occupied, Empty, Occupied, Empty, Empty, ... + // Should not be able to expand header from the second Empty to the second Occupied. + table.cell(y: 0)[a], + table.cell(y: 2)[a], + table.header( + table.hline(stroke: red), + table.hline(y: 3, stroke: aqua), + // Error: 5-24 cell would cause header to expand to non-empty row 2 + // Hint: 5-24 try moving its cells to available rows + table.cell(y: 2)[b], + ) +) + +--- grid-header-auto-pos-cell-inside-conflicts-with-row-after --- +#set page(margin: 2pt) +#set text(6pt) +#table( + columns: 2, + inset: 1.5pt, + table.cell(y: 1)[a], + table.header( + [b], [c], + // Error: 6-7 cell would cause header to expand to non-empty row 1 + // Hint: 6-7 try moving its cells to available rows + [d], + ), +) + +--- grid-header-row-pos-cell-inside-conflicts-with-row-after --- +#set page(margin: 2pt) +#set text(6pt) +#table( + columns: 2, + inset: 1.5pt, + table.cell(y: 2)[a], + table.header( + [b], [c], + // Error: 5-24 cell would cause header to expand to non-empty row 2 + // Hint: 5-24 try moving its cells to available rows + table.cell(y: 3)[d], + ), +) + +--- issue-5359-column-override-stays-inside-header --- +#table( + columns: 3, + [Outside], + // Error: 1:3-4:4 header must start at the first row + // Hint: 1:3-4:4 remove any rows before the header + table.header( + [A], table.cell(x: 1)[B], [C], + table.cell(x: 1)[D], + ), +)