summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--crates/typst/src/layout/grid/layout.rs810
-rw-r--r--crates/typst/src/layout/grid/lines.rs680
-rw-r--r--crates/typst/src/layout/grid/mod.rs21
-rw-r--r--crates/typst/src/layout/grid/rowspans.rs864
-rw-r--r--crates/typst/src/model/table.rs22
-rw-r--r--tests/ref/bugs/grid-4.pngbin0 -> 1117 bytes
-rw-r--r--tests/ref/layout/grid-rowspan-basic.pngbin0 -> 94597 bytes
-rw-r--r--tests/ref/layout/grid-rowspan-split-1.pngbin0 -> 30406 bytes
-rw-r--r--tests/ref/layout/grid-rowspan-split-2.pngbin0 -> 11282 bytes
-rw-r--r--tests/ref/layout/grid-rowspan-split-3.pngbin0 -> 99690 bytes
-rw-r--r--tests/ref/layout/grid-rtl.pngbin44946 -> 72464 bytes
-rw-r--r--tests/ref/layout/grid-stroke.pngbin48899 -> 55633 bytes
-rw-r--r--tests/typ/bugs/grid-4.typ17
-rw-r--r--tests/typ/layout/grid-colspan.typ2
-rw-r--r--tests/typ/layout/grid-positioning.typ8
-rw-r--r--tests/typ/layout/grid-rowspan-basic.typ211
-rw-r--r--tests/typ/layout/grid-rowspan-split-1.typ89
-rw-r--r--tests/typ/layout/grid-rowspan-split-2.typ37
-rw-r--r--tests/typ/layout/grid-rowspan-split-3.typ108
-rw-r--r--tests/typ/layout/grid-rtl.typ41
-rw-r--r--tests/typ/layout/grid-stroke.typ14
21 files changed, 2652 insertions, 272 deletions
diff --git a/crates/typst/src/layout/grid/layout.rs b/crates/typst/src/layout/grid/layout.rs
index 83dbf069..a27e4269 100644
--- a/crates/typst/src/layout/grid/layout.rs
+++ b/crates/typst/src/layout/grid/layout.rs
@@ -9,6 +9,7 @@ use super::lines::{
generate_line_segments, hline_stroke_at_column, vline_stroke_at_row, Line,
LinePosition, LineSegment,
};
+use super::rowspans::{Rowspan, UnbreakableRowGroup};
use crate::diag::{
bail, At, Hint, HintedStrResult, HintedString, SourceResult, StrResult,
};
@@ -172,6 +173,8 @@ pub struct Cell {
pub fill: Option<Paint>,
/// The amount of columns spanned by the cell.
pub colspan: NonZeroUsize,
+ /// The amount of rows spanned by the cell.
+ pub rowspan: NonZeroUsize,
/// The cell's stroke.
///
/// We use an Arc to avoid unnecessary space usage when all sides are the
@@ -185,6 +188,10 @@ pub struct Cell {
/// override their own stroke properties (and thus have less priority when
/// defining with which stroke to draw grid lines around this cell).
pub stroke_overridden: Sides<bool>,
+ /// Whether rows spanned by this cell can be placed in different pages.
+ /// By default, a cell spanning only fixed-size rows is unbreakable, while
+ /// a cell spanning at least one `auto`-sized row is breakable.
+ pub breakable: bool,
}
impl From<Content> for Cell {
@@ -194,8 +201,10 @@ impl From<Content> for Cell {
body,
fill: None,
colspan: NonZeroUsize::ONE,
+ rowspan: NonZeroUsize::ONE,
stroke: Sides::splat(None),
stroke_overridden: Sides::splat(false),
+ breakable: true,
}
}
}
@@ -245,7 +254,7 @@ pub enum GridItem<T: ResolvableCell> {
stroke: Option<Arc<Stroke<Abs>>>,
/// The span of the corresponding line element.
span: Span,
- /// The line's position. "before" here means on top of row 'y', while
+ /// The line's position. "before" here means on top of row `y`, while
/// "after" means below it.
position: LinePosition,
},
@@ -258,7 +267,7 @@ pub enum GridItem<T: ResolvableCell> {
stroke: Option<Arc<Stroke<Abs>>>,
/// The span of the corresponding line element.
span: Span,
- /// The line's position. "before" here means to the left of column 'x',
+ /// The line's position. "before" here means to the left of column `x`,
/// while "after" means to its right (both considering LTR).
position: LinePosition,
},
@@ -270,7 +279,8 @@ pub enum GridItem<T: ResolvableCell> {
/// the table, and may have property overrides.
pub trait ResolvableCell {
/// Resolves the cell's fields, given its coordinates and default grid-wide
- /// fill, align, inset and stroke properties.
+ /// fill, align, inset and stroke properties, plus the expected value of
+ /// the `breakable` field.
/// Returns a final Cell.
#[allow(clippy::too_many_arguments)]
fn resolve_cell(
@@ -281,6 +291,7 @@ pub trait ResolvableCell {
align: Smart<Alignment>,
inset: Sides<Option<Rel<Length>>>,
stroke: Sides<Option<Option<Arc<Stroke<Abs>>>>>,
+ breakable: bool,
styles: StyleChain,
) -> Cell;
@@ -293,6 +304,9 @@ pub trait ResolvableCell {
/// The amount of columns spanned by this cell.
fn colspan(&self, styles: StyleChain) -> NonZeroUsize;
+ /// The amount of rows spanned by this cell.
+ fn rowspan(&self, styles: StyleChain) -> NonZeroUsize;
+
/// The cell's span, for errors.
fn span(&self) -> Span;
}
@@ -366,6 +380,27 @@ impl CellGrid {
let mut pending_vlines: Vec<(Span, Line)> = vec![];
let has_gutter = gutter.any(|tracks| !tracks.is_empty());
+ // Resolve the breakability of a cell, based on whether or not it spans
+ // an auto row.
+ 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
@@ -471,6 +506,7 @@ impl CellGrid {
let x = resolved_index % c;
let y = resolved_index / c;
let colspan = cell.colspan(styles).get();
+ let rowspan = cell.rowspan(styles).get();
if colspan > c - x {
bail!(
@@ -480,11 +516,17 @@ impl CellGrid {
)
}
- let Some(largest_index) = resolved_index.checked_add(colspan - 1) else {
+ 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 colspan"
+ hint: "try reducing the cell's rowspan or colspan"
)
};
@@ -497,6 +539,7 @@ impl CellGrid {
align.resolve(engine, styles, x, y)?,
inset.resolve(engine, styles, x, y)?,
stroke.resolve(engine, styles, x, y)?,
+ resolve_breakable(y, rowspan),
styles,
);
@@ -542,23 +585,29 @@ impl CellGrid {
*slot = Some(Entry::Cell(cell));
- // Now, if the cell spans more than one column, we fill the spanned
- // positions in the grid with Entry::Merged pointing to the
+ // 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 (offset, slot) in resolved_cells[resolved_index..][..colspan]
- .iter_mut()
- .enumerate()
- .skip(1)
- {
- if slot.is_some() {
- let spanned_x = x + offset;
- bail!(
- cell_span,
- "cell would span a previously placed cell at column {spanned_x}, row {y}";
- hint: "try specifying your cells in a different order or reducing the cell's colspan"
- )
+ 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 });
}
- *slot = Some(Entry::Merged { parent: resolved_index });
}
}
@@ -583,6 +632,7 @@ impl CellGrid {
align.resolve(engine, styles, x, y)?,
inset.resolve(engine, styles, x, y)?,
stroke.resolve(engine, styles, x, y)?,
+ resolve_breakable(y, 1),
styles,
);
Ok(Entry::Cell(new_cell))
@@ -760,16 +810,6 @@ impl CellGrid {
self.entry(x, y).and_then(Entry::as_cell)
}
- /// Returns the parent cell of the grid entry at the given position.
- /// - If the entry at the given position is a cell, returns it.
- /// - If it is a merged cell, returns the parent cell.
- /// - If it is a gutter cell, returns None.
- #[track_caller]
- pub(super) fn parent_cell(&self, x: usize, y: usize) -> Option<&Cell> {
- self.parent_cell_position(x, y)
- .and_then(|Axes { x, y }| self.cell(x, y))
- }
-
/// Returns the position of the parent cell of the grid entry at the given
/// position. It is guaranteed to have a non-gutter, non-merged cell at
/// the returned position, due to how the grid is built.
@@ -792,6 +832,72 @@ impl CellGrid {
}
})
}
+
+ /// Returns the position of the actual parent cell of a merged position,
+ /// even if the given position is gutter, in which case we return the
+ /// parent of the nearest adjacent content cell which could possibly span
+ /// the given gutter position. If the given position is not a gutter cell,
+ /// then this function will return the same as `parent_cell_position` would.
+ /// If the given position is a gutter cell, but no cell spans it, returns
+ /// `None`.
+ ///
+ /// This is useful for lines. A line needs to check if a cell next to it
+ /// has a stroke override - even at a gutter position there could be a
+ /// stroke override, since a cell could be merged with two cells at both
+ /// ends of the gutter cell (e.g. to its left and to its right), and thus
+ /// that cell would impose a stroke under the gutter. This function allows
+ /// getting the position of that cell (which spans the given gutter
+ /// position, if it is gutter), if it exists; otherwise returns None (it's
+ /// gutter and no cell spans it).
+ #[track_caller]
+ pub(super) fn effective_parent_cell_position(
+ &self,
+ x: usize,
+ y: usize,
+ ) -> Option<Axes<usize>> {
+ if self.has_gutter {
+ // If (x, y) is a gutter cell, we skip it (skip a gutter column and
+ // row) to the nearest adjacent content cell, in the direction
+ // which merged cells grow toward (increasing x and increasing y),
+ // such that we can verify if that adjacent cell is merged with the
+ // gutter cell by checking if its parent would come before (x, y).
+ // Otherwise, no cell is merged with this gutter cell, and we
+ // return None.
+ self.parent_cell_position(x + x % 2, y + y % 2)
+ .filter(|&parent| parent.x <= x && parent.y <= y)
+ } else {
+ self.parent_cell_position(x, y)
+ }
+ }
+
+ /// Checks if the track with the given index is gutter.
+ /// Does not check if the index is a valid track.
+ #[inline]
+ pub(super) fn is_gutter_track(&self, index: usize) -> bool {
+ self.has_gutter && index % 2 == 1
+ }
+
+ /// Returns the effective colspan of a cell, considering the gutters it
+ /// might span if the grid has gutters.
+ #[inline]
+ pub(super) fn effective_colspan_of_cell(&self, cell: &Cell) -> usize {
+ if self.has_gutter {
+ 2 * cell.colspan.get() - 1
+ } else {
+ cell.colspan.get()
+ }
+ }
+
+ /// Returns the effective rowspan of a cell, considering the gutters it
+ /// might span if the grid has gutters.
+ #[inline]
+ pub(super) fn effective_rowspan_of_cell(&self, cell: &Cell) -> usize {
+ if self.has_gutter {
+ 2 * cell.rowspan.get() - 1
+ } else {
+ cell.rowspan.get()
+ }
+ }
}
/// Given a cell's requested x and y, the vector with the resolved cell
@@ -889,27 +995,34 @@ fn resolve_cell_position(
/// Performs grid layout.
pub struct GridLayouter<'a> {
/// The grid of cells.
- grid: &'a CellGrid,
+ pub(super) grid: &'a CellGrid,
/// The regions to layout children into.
- regions: Regions<'a>,
+ pub(super) regions: Regions<'a>,
/// The inherited styles.
- styles: StyleChain<'a>,
+ pub(super) styles: StyleChain<'a>,
/// Resolved column sizes.
- rcols: Vec<Abs>,
+ pub(super) rcols: Vec<Abs>,
/// The sum of `rcols`.
- width: Abs,
+ pub(super) width: Abs,
/// Resolve row sizes, by region.
- rrows: Vec<Vec<RowPiece>>,
+ pub(super) rrows: Vec<Vec<RowPiece>>,
/// Rows in the current region.
- lrows: Vec<Row>,
+ pub(super) lrows: Vec<Row>,
+ /// The amount of unbreakable rows remaining to be laid out in the
+ /// current unbreakable row group. While this is positive, no region breaks
+ /// should occur.
+ pub(super) unbreakable_rows_left: usize,
+ /// Rowspans not yet laid out because not all of their spanned rows were
+ /// laid out yet.
+ pub(super) rowspans: Vec<Rowspan>,
/// The initial size of the current region before we started subtracting.
- initial: Size,
+ pub(super) initial: Size,
/// Frames for finished regions.
- finished: Vec<Frame>,
+ pub(super) finished: Vec<Frame>,
/// Whether this is an RTL grid.
- is_rtl: bool,
+ pub(super) is_rtl: bool,
/// The span of the grid element.
- span: Span,
+ pub(super) span: Span,
}
/// Details about a resulting row piece.
@@ -923,9 +1036,12 @@ pub struct RowPiece {
/// Produced by initial row layout, auto and relative rows are already finished,
/// fractional rows not yet.
-enum Row {
+pub(super) enum Row {
/// Finished row frame of auto or relative row with y index.
- Frame(Frame, usize),
+ /// The last parameter indicates whether or not this is the last region
+ /// where this row is laid out, and it can only be false when a row uses
+ /// `layout_multi_row`, which in turn is only used by breakable auto rows.
+ Frame(Frame, usize, bool),
/// Fractional row with y index.
Fr(Fr, usize),
}
@@ -953,6 +1069,8 @@ impl<'a> GridLayouter<'a> {
width: Abs::zero(),
rrows: vec![],
lrows: vec![],
+ unbreakable_rows_left: 0,
+ rowspans: vec![],
initial: regions.size,
finished: vec![],
is_rtl: TextElem::dir_in(styles) == Dir::RTL,
@@ -966,27 +1084,59 @@ impl<'a> GridLayouter<'a> {
for y in 0..self.grid.rows.len() {
// Skip to next region if current one is full, but only for content
- // rows, not for gutter rows.
- if self.regions.is_full() && (!self.grid.has_gutter || y % 2 == 0) {
+ // rows, not for gutter rows, and only if we aren't laying out an
+ // unbreakable group of rows.
+ let is_content_row = !self.grid.is_gutter_track(y);
+ if self.unbreakable_rows_left == 0 && self.regions.is_full() && is_content_row
+ {
self.finish_region(engine)?;
}
- match self.grid.rows[y] {
- Sizing::Auto => self.layout_auto_row(engine, y)?,
- Sizing::Rel(v) => self.layout_relative_row(engine, v, y)?,
- Sizing::Fr(v) => self.lrows.push(Row::Fr(v, y)),
+ if is_content_row {
+ // Gutter rows have no rowspans or possibly unbreakable cells.
+ self.check_for_rowspans(y);
+ self.check_for_unbreakable_rows(y, engine)?;
}
+
+ // Don't layout gutter rows at the top of a region.
+ if is_content_row || !self.lrows.is_empty() {
+ match self.grid.rows[y] {
+ Sizing::Auto => self.layout_auto_row(engine, y)?,
+ Sizing::Rel(v) => self.layout_relative_row(engine, v, y)?,
+ Sizing::Fr(v) => self.lrows.push(Row::Fr(v, y)),
+ }
+ }
+
+ self.unbreakable_rows_left = self.unbreakable_rows_left.saturating_sub(1);
}
self.finish_region(engine)?;
+ // Layout any missing rowspans.
+ // There are only two possibilities for rowspans not yet laid out
+ // (usually, a rowspan is laid out as soon as its last row, or any row
+ // after it, is laid out):
+ // 1. The rowspan was fully empty and only spanned fully empty auto
+ // rows, which were all prevented from being laid out. Those rowspans
+ // are ignored by 'layout_rowspan', and are not of any concern.
+ //
+ // 2. The rowspan's last row was an auto row at the last region which
+ // was not laid out, and no other rows were laid out after it. Those
+ // might still need to be laid out, so we check for them.
+ for rowspan in std::mem::take(&mut self.rowspans) {
+ self.layout_rowspan(rowspan, None, engine)?;
+ }
+
self.render_fills_strokes()
}
/// Add lines and backgrounds.
fn render_fills_strokes(mut self) -> SourceResult<Fragment> {
let mut finished = std::mem::take(&mut self.finished);
- for (frame, rows) in finished.iter_mut().zip(&self.rrows) {
+ let frame_amount = finished.len();
+ for ((frame_index, frame), rows) in
+ finished.iter_mut().enumerate().zip(&self.rrows)
+ {
if self.rcols.is_empty() || rows.is_empty() {
continue;
}
@@ -1033,7 +1183,7 @@ impl<'a> GridLayouter<'a> {
// lines before it, not after).
x / 2
})
- .map(|vlines| &**vlines)
+ .map(Vec::as_slice)
.unwrap_or(&[]);
let tracks = rows.iter().map(|row| (row.y, row.height));
@@ -1077,13 +1227,14 @@ impl<'a> GridLayouter<'a> {
// Additionally, determine their indices (the indices of the
// rows they are drawn on top of). In principle, this will
// correspond to the rows' indices directly, except for the
- // first and last hlines, which must be 0 and (amount of rows)
- // respectively, as they are always drawn (due to being part of
- // the table's border).
- let hline_indices = std::iter::once(0)
- .chain(rows.iter().map(|piece| piece.y).skip(1))
+ // last hline index, which must be (amount of rows) in order to
+ // draw the table's bottom border.
+ let hline_indices = rows
+ .iter()
+ .map(|piece| piece.y)
.chain(std::iter::once(self.grid.rows.len()));
+ let mut prev_y = None;
for (y, dy) in hline_indices.zip(hline_offsets) {
let is_bottom_border = y == self.grid.rows.len();
let hlines_at_row = self
@@ -1098,10 +1249,37 @@ impl<'a> GridLayouter<'a> {
// these index operations.
y / 2
})
- .map(|hlines| &**hlines)
- .unwrap_or(&[]);
+ .map(Vec::as_slice)
+ .unwrap_or(&[])
+ .iter()
+ .chain(if prev_y.is_none() && y != 0 {
+ // For lines at the top of the region, give priority to
+ // the lines at the top border.
+ self.grid.hlines.first().map(Vec::as_slice).unwrap_or(&[])
+ } else {
+ // When not at the top of the region, no border lines
+ // to consider.
+ // When at the top of the region but at the first row,
+ // its own lines are already the border lines.
+ &[]
+ });
+
let tracks = self.rcols.iter().copied().enumerate();
+ // Normally, given an hline above row y, the row above it is
+ // 'y - 1' (if y > 0). However, sometimes that's not true, for
+ // example if 'y - 1' is in a previous region, or if 'y - 1'
+ // was an empty auto row which was removed. Therefore, we tell
+ // the hlines at this index which row is actually above them in
+ // the laid out region so they can include that row's bottom
+ // strokes in the folding process.
+ let local_top_y = prev_y;
+
+ // When we're in the last region, the bottom border stroke
+ // doesn't necessarily gain priority like it does in previous
+ // regions.
+ let in_last_region = frame_index + 1 == frame_amount;
+
// Determine all different line segments we have to draw in
// this row, and convert them to points and shapes.
let segments = generate_line_segments(
@@ -1110,7 +1288,17 @@ impl<'a> GridLayouter<'a> {
y,
hlines_at_row,
is_bottom_border,
- hline_stroke_at_column,
+ |grid, y, x, stroke| {
+ hline_stroke_at_column(
+ grid,
+ rows,
+ local_top_y,
+ in_last_region,
+ y,
+ x,
+ stroke,
+ )
+ },
)
.map(|segment| {
let LineSegment { stroke, offset: dx, length, priority } = segment;
@@ -1130,6 +1318,8 @@ impl<'a> GridLayouter<'a> {
// Draw later (after we sort all lines below.)
lines.extend(segments);
+
+ prev_y = Some(y);
}
// Sort by increasing thickness, so that we draw larger strokes
@@ -1151,10 +1341,72 @@ impl<'a> GridLayouter<'a> {
for (x, &col) in self.rcols.iter().enumerate().rev_if(self.is_rtl) {
let mut dy = Abs::zero();
for row in rows {
- if let Some(cell) = self.grid.cell(x, row.y) {
+ // We want to only draw the fill starting at the parent
+ // positions of cells. However, sometimes the parent
+ // position is absent from the current region, either
+ // because the first few rows of a rowspan were empty auto
+ // rows and thus removed from layout, or because the parent
+ // cell was in a previous region (in which case we'd want
+ // to draw its fill again, in the current region).
+ // Therefore, we first analyze the parent position to see
+ // if the current row would be the first row spanned by the
+ // parent cell in this region. If so, this means we have to
+ // start drawing the cell's fill here. If not, we ignore
+ // the position `(x, row.y)`, as its fill will already have
+ // been rendered before.
+ //
+ // Note: In the case of gutter rows, we have to check the
+ // row below before discarding them fully, because a
+ // gutter row might be the first row spanned by a rowspan
+ // in this region (e.g. if the first row was empty and
+ // therefore removed), so its fill could start in that
+ // gutter row. That's why we use
+ // 'effective_parent_cell_position'.
+ let parent = self
+ .grid
+ .effective_parent_cell_position(x, row.y)
+ .filter(|parent| {
+ // Ensure this is the first column spanned by the
+ // cell before drawing its fill, otherwise we
+ // already rendered its fill in a previous
+ // iteration of the outer loop (and/or this is a
+ // gutter column, which we ignore).
+ //
+ // Additionally, we should only draw the fill when
+ // this row is the local parent Y for this cell,
+ // that is, the first row spanned by the cell's
+ // parent in this region, because if the parent
+ // cell's fill was already drawn in a previous
+ // region, we must render it again in later regions
+ // spanned by that cell. Note that said condition
+ // always holds when the current cell has a rowspan
+ // of 1 and we're not currently at a gutter row.
+ parent.x == x
+ && (parent.y == row.y
+ || rows
+ .iter()
+ .find(|row| row.y >= parent.y)
+ .is_some_and(|first_spanned_row| {
+ first_spanned_row.y == row.y
+ }))
+ });
+
+ if let Some(parent) = parent {
+ let cell = self.grid.cell(parent.x, parent.y).unwrap();
let fill = cell.fill.clone();
if let Some(fill) = fill {
- let width = self.cell_spanned_width(x, cell.colspan.get());
+ let rowspan = self.grid.effective_rowspan_of_cell(cell);
+ let height = if rowspan == 1 {
+ row.height
+ } else {
+ rows.iter()
+ .filter(|row| {
+ (parent.y..parent.y + rowspan).contains(&row.y)
+ })
+ .map(|row| row.height)
+ .sum()
+ };
+ let width = self.cell_spanned_width(cell, x);
// In the grid, cell colspans expand to the right,
// so we're at the leftmost (lowest 'x') column
// spanned by the cell. However, in RTL, cells
@@ -1167,7 +1419,7 @@ impl<'a> GridLayouter<'a> {
let offset =
if self.is_rtl { -width + col } else { Abs::zero() };
let pos = Point::new(dx + offset, dy);
- let size = Size::new(width, row.height);
+ let size = Size::new(width, height);
let rect = Geometry::Rect(size).filled(fill);
fills.push((pos, FrameItem::Shape(rect, self.span)));
}
@@ -1237,12 +1489,9 @@ impl<'a> GridLayouter<'a> {
/// Total width spanned by the cell (among resolved columns).
/// Includes spanned gutter columns.
- fn cell_spanned_width(&self, x: usize, colspan: usize) -> Abs {
- self.rcols
- .iter()
- .skip(x)
- .take(if self.grid.has_gutter { 2 * colspan - 1 } else { colspan })
- .sum()
+ pub(super) fn cell_spanned_width(&self, cell: &Cell, x: usize) -> Abs {
+ let colspan = self.grid.effective_colspan_of_cell(cell);
+ self.rcols.iter().skip(x).take(colspan).sum()
}
/// Measure the size that is available to auto columns.
@@ -1272,25 +1521,23 @@ impl<'a> GridLayouter<'a> {
let mut resolved = Abs::zero();
for y in 0..self.grid.rows.len() {
// We get the parent cell in case this is a merged position.
- let Some(Axes { x: parent_x, y: parent_y }) =
- self.grid.parent_cell_position(x, y)
- else {
+ let Some(parent) = self.grid.parent_cell_position(x, y) else {
continue;
};
- let cell = self.grid.cell(parent_x, parent_y).unwrap();
- let colspan = cell.colspan.get();
+ if parent.y != y {
+ // Don't check the width of rowspans more than once.
+ continue;
+ }
+ let cell = self.grid.cell(parent.x, parent.y).unwrap();
+ let colspan = self.grid.effective_colspan_of_cell(cell);
if colspan > 1 {
let last_spanned_auto_col = self
.grid
.cols
.iter()
.enumerate()
- .skip(parent_x)
- .take(if self.grid.has_gutter {
- 2 * colspan - 1
- } else {
- colspan
- })
+ .skip(parent.x)
+ .take(colspan)
.rev()
.find(|(_, col)| **col == Sizing::Auto)
.map(|(x, _)| x);
@@ -1307,7 +1554,7 @@ impl<'a> GridLayouter<'a> {
&& !all_frac_cols.is_empty()
&& all_frac_cols
.iter()
- .all(|x| (parent_x..parent_x + colspan).contains(x))
+ .all(|x| (parent.x..parent.x + colspan).contains(x))
{
// Additionally, as a heuristic, a colspan won't affect the
// size of auto columns if it already spans all fractional
@@ -1319,14 +1566,30 @@ impl<'a> GridLayouter<'a> {
continue;
}
- // For relative rows, we can already resolve the correct
- // base and for auto and fr we could only guess anyway.
- let height = match self.grid.rows[y] {
- Sizing::Rel(v) => {
- v.resolve(self.styles).relative_to(self.regions.base().y)
- }
- _ => self.regions.base().y,
- };
+ // Sum the heights of spanned rows to find the expected
+ // available height for the cell, unless it spans a fractional
+ // or auto column.
+ let rowspan = self.grid.effective_rowspan_of_cell(cell);
+ let height = self
+ .grid
+ .rows
+ .iter()
+ .skip(y)
+ .take(rowspan)
+ .try_fold(Abs::zero(), |acc, col| {
+ // For relative rows, we can already resolve the correct
+ // base and for auto and fr we could only guess anyway.
+ match col {
+ Sizing::Rel(v) => Some(
+ acc + v
+ .resolve(self.styles)
+ .relative_to(self.regions.base().y),
+ ),
+ _ => None,
+ }
+ })
+ .unwrap_or_else(|| self.regions.base().y);
+
// Don't expand this auto column more than the cell actually
// needs. To do this, we check how much the other, previously
// resolved columns provide to the cell in terms of width
@@ -1341,7 +1604,7 @@ impl<'a> GridLayouter<'a> {
// an auto column. One mitigation for this is the heuristic
// used above to not expand the last auto column spanned by a
// cell if it spans all fractional columns in a finite region.
- let already_covered_width = self.cell_spanned_width(parent_x, colspan);
+ let already_covered_width = self.cell_spanned_width(cell, parent.x);
let size = Size::new(available, height);
let pod = Regions::one(size, Axes::splat(false));
@@ -1408,11 +1671,18 @@ impl<'a> GridLayouter<'a> {
fn layout_auto_row(&mut self, engine: &mut Engine, y: usize) -> SourceResult<()> {
// Determine the size for each region of the row. If the first region
// ends up empty for some column, skip the region and remeasure.
- let mut resolved = match self.measure_auto_row(engine, y, true)? {
+ let mut resolved = match self.measure_auto_row(
+ engine,
+ y,
+ true,
+ self.unbreakable_rows_left,
+ None,
+ )? {
Some(resolved) => resolved,
None => {
self.finish_region(engine)?;
- self.measure_auto_row(engine, y, false)?.unwrap()
+ self.measure_auto_row(engine, y, false, self.unbreakable_rows_left, None)?
+ .unwrap()
}
};
@@ -1424,7 +1694,7 @@ impl<'a> GridLayouter<'a> {
// Layout into a single region.
if let &[first] = resolved.as_slice() {
let frame = self.layout_single_row(engine, first, y)?;
- self.push_row(frame, y);
+ self.push_row(frame, y, true);
return Ok(());
}
@@ -1444,7 +1714,7 @@ impl<'a> GridLayouter<'a> {
let fragment = self.layout_multi_row(engine, &resolved, y)?;
let len = fragment.len();
for (i, frame) in fragment.into_iter().enumerate() {
- self.push_row(frame, y);
+ self.push_row(frame, y, i + 1 == len);
if i + 1 < len {
self.finish_region(engine)?;
}
@@ -1455,43 +1725,175 @@ impl<'a> GridLayouter<'a> {
/// Measure the regions sizes of an auto row. The option is always `Some(_)`
/// if `can_skip` is false.
- fn measure_auto_row(
- &mut self,
+ /// If `unbreakable_rows_left` is positive, this function shall only return
+ /// a single frame. Useful when an unbreakable rowspan crosses this auto
+ /// row.
+ /// The `row_group_data` option is used within the unbreakable row group
+ /// simulator to predict the height of the auto row if previous rows in the
+ /// group were placed in the same region.
+ pub(super) fn measure_auto_row(
+ &self,
engine: &mut Engine,
y: usize,
can_skip: bool,
+ unbreakable_rows_left: usize,
+ row_group_data: Option<&UnbreakableRowGroup>,
) -> SourceResult<Option<Vec<Abs>>> {
+ let breakable = unbreakable_rows_left == 0;
let mut resolved: Vec<Abs> = vec![];
+ let mut pending_rowspans: Vec<(usize, usize, Vec<Abs>)> = vec![];
for x in 0..self.rcols.len() {
- if let Some(cell) = self.grid.cell(x, y) {
+ // Get the parent cell in case this is a merged position.
+ let Some(parent) = self.grid.parent_cell_position(x, y) else {
+ // Skip gutter columns.
+ continue;
+ };
+ if parent.x != x {
+ // Only check the height of a colspan once.
+ continue;
+ }
+ // The parent cell is never a gutter or merged position.
+ let cell = self.grid.cell(parent.x, parent.y).unwrap();
+ let rowspan = self.grid.effective_rowspan_of_cell(cell);
+
+ if rowspan > 1 {
+ let last_spanned_auto_row = self
+ .grid
+ .rows
+ .iter()
+ .enumerate()
+ .skip(parent.y)
+ .take(rowspan)
+ .rev()
+ .find(|(_, &row)| row == Sizing::Auto)
+ .map(|(y, _)| y);
+
+ if last_spanned_auto_row != Some(y) {
+ // A rowspan should only affect the height of its last
+ // spanned auto row.
+ continue;
+ }
+ }
+
+ let measurement_data = self.prepare_auto_row_cell_measurement(
+ parent,
+ cell,
+ breakable,
+ row_group_data,
+ );
+ let size = Axes::new(measurement_data.width, measurement_data.height);
+ let backlog =
+ measurement_data.backlog.unwrap_or(&measurement_data.custom_backlog);
+
+ let pod = if !breakable {
+ // Force cell to fit into a single region when the row is
+ // unbreakable, even when it is a breakable rowspan, as a best
+ // effort.
+ let mut pod = Regions::one(size, self.regions.expand);
+ pod.full = measurement_data.full;
+
+ if measurement_data.frames_in_previous_regions > 0 {
+ // Best effort to conciliate a breakable rowspan which
+ // started at a previous region going through an
+ // unbreakable auto row. Ensure it goes through previously
+ // laid out regions, but stops at this one when measuring.
+ pod.backlog = backlog;
+ }
+
+ pod
+ } else {
+ // This row is breakable, so measure the cell normally, with
+ // the initial height and backlog determined previously.
let mut pod = self.regions;
- pod.size.x = self.cell_spanned_width(x, cell.colspan.get());
-
- let frames = cell.measure(engine, self.styles, pod)?.into_frames();
-
- // Skip the first region if one cell in it is empty. Then,
- // remeasure.
- if let [first, rest @ ..] = frames.as_slice() {
- if can_skip
- && first.is_empty()
- && rest.iter().any(|frame| !frame.is_empty())
- {
- return Ok(None);
- }
+ pod.size = size;
+ pod.backlog = backlog;
+ pod.full = measurement_data.full;
+ pod
+ };
+
+ let frames = cell.measure(engine, self.styles, pod)?.into_frames();
+
+ // Skip the first region if one cell in it is empty. Then,
+ // remeasure.
+ if let Some([first, rest @ ..]) =
+ frames.get(measurement_data.frames_in_previous_regions..)
+ {
+ if can_skip
+ && breakable
+ && first.is_empty()
+ && rest.iter().any(|frame| !frame.is_empty())
+ {
+ return Ok(None);
}
+ }
- let mut sizes = frames.iter().map(|frame| frame.height());
- for (target, size) in resolved.iter_mut().zip(&mut sizes) {
- target.set_max(size);
+ // Skip frames from previous regions if applicable.
+ let mut sizes = frames
+ .iter()
+ .skip(measurement_data.frames_in_previous_regions)
+ .map(|frame| frame.height())
+ .collect::<Vec<_>>();
+
+ // Don't expand this row more than the cell needs.
+ // To figure out how much height the cell needs, we must first
+ // subtract, from the cell's expected height, the already resolved
+ // heights of its spanned rows. Note that this is the last spanned
+ // auto row, so all previous auto rows were already resolved, as
+ // well as fractional rows in previous regions.
+ // Additionally, we subtract the heights of fixed-size rows which
+ // weren't laid out yet, since those heights won't change in
+ // principle.
+ // Upcoming fractional rows are ignored.
+ // Upcoming gutter rows might be removed, so we need to simulate
+ // them.
+ if rowspan > 1 {
+ let should_simulate = self.prepare_rowspan_sizes(
+ y,
+ &mut sizes,
+ cell,
+ parent.y,
+ rowspan,
+ unbreakable_rows_left,
+ &measurement_data,
+ );
+
+ if should_simulate {
+ // Rowspan spans gutter and is breakable. We'll need to
+ // run a simulation to predict how much this auto row needs
+ // to expand so that the rowspan's contents fit into the
+ // table.
+ pending_rowspans.push((parent.y, rowspan, sizes));
+ continue;
}
+ }
+
+ let mut sizes = sizes.into_iter();
- // New heights are maximal by virtue of being new. Note that
- // this extend only uses the rest of the sizes iterator.
- resolved.extend(sizes);
+ for (target, size) in resolved.iter_mut().zip(&mut sizes) {
+ target.set_max(size);
}
+
+ // New heights are maximal by virtue of being new. Note that
+ // this extend only uses the rest of the sizes iterator.
+ resolved.extend(sizes);
+ }
+
+ // Simulate the upcoming regions in order to predict how much we need
+ // to expand this auto row for rowspans which span gutter.
+ if !pending_rowspans.is_empty() {
+ self.simulate_and_measure_rowspans_in_auto_row(
+ y,
+ &mut resolved,
+ &pending_rowspans,
+ unbreakable_rows_left,
+ row_group_data,
+ engine,
+ )?;
}
+ debug_assert!(breakable || resolved.len() <= 1);
+
Ok(Some(resolved))
}
@@ -1506,18 +1908,22 @@ impl<'a> GridLayouter<'a> {
let resolved = v.resolve(self.styles).relative_to(self.regions.base().y);
let frame = self.layout_single_row(engine, resolved, y)?;
- // Skip to fitting region.
+ // Skip to fitting region, but only if we aren't part of an unbreakable
+ // row group.
let height = frame.height();
- while !self.regions.size.y.fits(height) && !self.regions.in_last() {
+ while self.unbreakable_rows_left == 0
+ && !self.regions.size.y.fits(height)
+ && !self.regions.in_last()
+ {
self.finish_region(engine)?;
// Don't skip multiple regions for gutter and don't push a row.
- if self.grid.has_gutter && y % 2 == 1 {
+ if self.grid.is_gutter_track(y) {
return Ok(());
}
}
- self.push_row(frame, y);
+ self.push_row(frame, y, true);
Ok(())
}
@@ -1539,27 +1945,31 @@ impl<'a> GridLayouter<'a> {
// Reverse the column order when using RTL.
for (x, &rcol) in self.rcols.iter().enumerate().rev_if(self.is_rtl) {
if let Some(cell) = self.grid.cell(x, y) {
- let width = self.cell_spanned_width(x, cell.colspan.get());
- let size = Size::new(width, height);
- let mut pod = Regions::one(size, Axes::splat(true));
- if self.grid.rows[y] == Sizing::Auto {
- pod.full = self.regions.full;
- }
- let mut frame = cell.layout(engine, self.styles, pod)?.into_frame();
- if self.is_rtl {
- // In the grid, cell colspans expand to the right,
- // so we're at the leftmost (lowest 'x') column
- // spanned by the cell. However, in RTL, cells
- // expand to the left. Therefore, without the
- // offset below, the cell's contents would be laid out
- // starting at its rightmost visual position and extend
- // over to unrelated cells to its right in RTL.
- // We avoid this by ensuring the rendered cell starts at
- // the very left of the cell, even with colspan > 1.
- let offset = Point::with_x(-width + rcol);
- frame.translate(offset);
+ // Rowspans have a separate layout step
+ if cell.rowspan.get() == 1 {
+ let width = self.cell_spanned_width(cell, x);
+ let size = Size::new(width, height);
+ let mut pod = Regions::one(size, Axes::splat(true));
+ if self.grid.rows[y] == Sizing::Auto {
+ pod.full = self.regions.full;
+ }
+ let frame = cell.layout(engine, self.styles, pod)?.into_frame();
+ let mut pos = pos;
+ if self.is_rtl {
+ // In the grid, cell colspans expand to the right,
+ // so we're at the leftmost (lowest 'x') column
+ // spanned by the cell. However, in RTL, cells
+ // expand to the left. Therefore, without the
+ // offset below, the cell's contents would be laid out
+ // starting at its rightmost visual position and extend
+ // over to unrelated cells to its right in RTL.
+ // We avoid this by ensuring the rendered cell starts at
+ // the very left of the cell, even with colspan > 1.
+ let offset = -width + rcol;
+ pos.x += offset;
+ }
+ output.push_frame(pos, frame);
}
- output.push_frame(pos, frame);
}
pos.x += rcol;
@@ -1591,17 +2001,21 @@ impl<'a> GridLayouter<'a> {
let mut pos = Point::zero();
for (x, &rcol) in self.rcols.iter().enumerate().rev_if(self.is_rtl) {
if let Some(cell) = self.grid.cell(x, y) {
- let width = self.cell_spanned_width(x, cell.colspan.get());
- pod.size.x = width;
-
- // Push the layouted frames into the individual output frames.
- let fragment = cell.layout(engine, self.styles, pod)?;
- for (output, mut frame) in outputs.iter_mut().zip(fragment) {
- if self.is_rtl {
- let offset = Point::with_x(-width + rcol);
- frame.translate(offset);
+ // Rowspans have a separate layout step
+ if cell.rowspan.get() == 1 {
+ let width = self.cell_spanned_width(cell, x);
+ pod.size.x = width;
+
+ // Push the layouted frames into the individual output frames.
+ let fragment = cell.layout(engine, self.styles, pod)?;
+ for (output, frame) in outputs.iter_mut().zip(fragment) {
+ let mut pos = pos;
+ if self.is_rtl {
+ let offset = -width + rcol;
+ pos.x += offset;
+ }
+ output.push_frame(pos, frame);
}
- output.push_frame(pos, frame);
}
}
@@ -1612,19 +2026,29 @@ impl<'a> GridLayouter<'a> {
}
/// Push a row frame into the current region.
- fn push_row(&mut self, frame: Frame, y: usize) {
+ /// The `is_last` parameter must be `true` if this is the last frame which
+ /// will be pushed for this particular row. It can be `false` for rows
+ /// spanning multiple regions.
+ fn push_row(&mut self, frame: Frame, y: usize, is_last: bool) {
self.regions.size.y -= frame.height();
- self.lrows.push(Row::Frame(frame, y));
+ self.lrows.push(Row::Frame(frame, y, is_last));
}
/// Finish rows for one region.
- fn finish_region(&mut self, engine: &mut Engine) -> SourceResult<()> {
+ pub(super) fn finish_region(&mut self, engine: &mut Engine) -> SourceResult<()> {
+ if self.lrows.last().is_some_and(|row| {
+ let (Row::Frame(_, y, _) | Row::Fr(_, y)) = row;
+ self.grid.is_gutter_track(*y)
+ }) {
+ // Remove the last row in the region if it is a gutter row.
+ self.lrows.pop().unwrap();
+ }
// Determine the height of existing rows in the region.
let mut used = Abs::zero();
let mut fr = Fr::zero();
for row in &self.lrows {
match row {
- Row::Frame(frame, _) => used += frame.height(),
+ Row::Frame(frame, _, _) => used += frame.height(),
Row::Fr(v, _) => fr += *v,
}
}
@@ -1640,19 +2064,89 @@ impl<'a> GridLayouter<'a> {
let mut output = Frame::soft(size);
let mut pos = Point::zero();
let mut rrows = vec![];
+ let current_region = self.finished.len();
// Place finished rows and layout fractional rows.
for row in std::mem::take(&mut self.lrows) {
- let (frame, y) = match row {
- Row::Frame(frame, y) => (frame, y),
+ let (frame, y, is_last) = match row {
+ Row::Frame(frame, y, is_last) => (frame, y, is_last),
Row::Fr(v, y) => {
let remaining = self.regions.full - used;
let height = v.share(fr, remaining);
- (self.layout_single_row(engine, height, y)?, y)
+ (self.layout_single_row(engine, height, y)?, y, true)
}
};
let height = frame.height();
+
+ // Ensure rowspans which span this row will have enough space to
+ // be laid out over it later.
+ for rowspan in self
+ .rowspans
+ .iter_mut()
+ .filter(|rowspan| (rowspan.y..rowspan.y + rowspan.rowspan).contains(&y))
+ {
+ // If the first region wasn't defined yet, it will have the the
+ // initial value of usize::MAX, so we can set it to the current
+ // region's index.
+ if rowspan.first_region > current_region {
+ rowspan.first_region = current_region;
+ // The rowspan starts at this region, precisely at this
+ // row. In other regions, it will start at dy = 0.
+ rowspan.dy = pos.y;
+ // When we layout the rowspan later, the full size of the
+ // pod must be equal to the full size of the first region
+ // it appears in.
+ rowspan.region_full = self.regions.full;
+ }
+ let amount_missing_heights = (current_region + 1)
+ .saturating_sub(rowspan.heights.len() + rowspan.first_region);
+
+ // Ensure the vector of heights is long enough such that the
+ // last height is the one for the current region.
+ rowspan
+ .heights
+ .extend(std::iter::repeat(Abs::zero()).take(amount_missing_heights));
+
+ // Ensure that, in this region, the rowspan will span at least
+ // this row.
+ *rowspan.heights.last_mut().unwrap() += height;
+ }
+
+ // Layout any rowspans which end at this row, but only if this is
+ // this row's last frame (to avoid having the rowspan stop being
+ // laid out at the first frame of the row).
+ if is_last {
+ // We use a for loop over indices to avoid borrow checking
+ // problems (we need to mutate the rowspans vector, so we can't
+ // have an iterator actively borrowing it). We keep a separate
+ // 'i' variable so we can step the counter back after removing
+ // a rowspan (see explanation below).
+ let mut i = 0;
+ while let Some(rowspan) = self.rowspans.get(i) {
+ if rowspan.y + rowspan.rowspan <= y + 1 {
+ // Rowspan ends at this or an earlier row, so we take
+ // it from the rowspans vector and lay it out.
+ // It's safe to pass the current region as a possible
+ // region for the rowspan to be laid out in, even if
+ // the rowspan's last row was at an earlier region,
+ // because the rowspan won't have an entry for this
+ // region in its 'heights' vector if it doesn't span
+ // any rows in this region.
+ //
+ // Here we don't advance the index counter ('i') because
+ // a new element we haven't checked yet in this loop
+ // will take the index of the now removed element, so
+ // we have to check the same index again in the next
+ // iteration.
+ let rowspan = self.rowspans.remove(i);
+ self.layout_rowspan(rowspan, Some(&mut output), engine)?;
+ } else {
+ i += 1;
+ }
+ }
+ }
+
output.push_frame(pos, frame);
rrows.push(RowPiece { height, y });
pos.y += height;
@@ -1669,7 +2163,9 @@ impl<'a> GridLayouter<'a> {
/// Turn an iterator of extents into an iterator of offsets before, in between,
/// and after the extents, e.g. [10mm, 5mm] -> [0mm, 10mm, 15mm].
-fn points(extents: impl IntoIterator<Item = Abs>) -> impl Iterator<Item = Abs> {
+pub(super) fn points(
+ extents: impl IntoIterator<Item = Abs>,
+) -> impl Iterator<Item = Abs> {
let mut offset = Abs::zero();
std::iter::once(Abs::zero()).chain(extents).map(move |extent| {
offset += extent;
diff --git a/crates/typst/src/layout/grid/lines.rs b/crates/typst/src/layout/grid/lines.rs
index 6eb43c9a..7084c71a 100644
--- a/crates/typst/src/layout/grid/lines.rs
+++ b/crates/typst/src/layout/grid/lines.rs
@@ -1,9 +1,9 @@
use std::num::NonZeroUsize;
use std::sync::Arc;
-use super::layout::CellGrid;
+use super::layout::{CellGrid, RowPiece};
use crate::foundations::{AlternativeFold, Fold};
-use crate::layout::{Abs, Axes};
+use crate::layout::Abs;
use crate::visualize::Stroke;
/// Represents an explicit grid line (horizontal or vertical) specified by the
@@ -67,7 +67,7 @@ pub(super) enum StrokePriority {
}
/// Data for a particular line segment in the grid as generated by
-/// 'generate_line_segments'.
+/// `generate_line_segments`.
#[derive(Debug, PartialEq, Eq)]
pub(super) struct LineSegment {
/// The stroke with which to draw this segment.
@@ -100,7 +100,7 @@ pub(super) struct LineSegment {
/// this index to fold with, if any). Contiguous segments with the same stroke
/// and priority are joined together automatically.
///
-/// The function should return 'None' for positions at which the line would
+/// The function should return `None` for positions at which the line would
/// otherwise cross a merged cell (for example, a vline could cross a colspan),
/// in which case a new segment should be drawn after the merged cell(s), even
/// if it would have the same stroke as the previous one.
@@ -115,13 +115,13 @@ pub(super) struct LineSegment {
///
/// Note that we assume that the tracks are sorted according to ascending
/// number, and they must be iterable over pairs of (number, size). For
-/// vertical lines, for instance, 'tracks' would describe the rows in the
+/// vertical lines, for instance, `tracks` would describe the rows in the
/// current region, as pairs (row index, row height).
-pub(super) fn generate_line_segments<'grid, F, I>(
+pub(super) fn generate_line_segments<'grid, F, I, L>(
grid: &'grid CellGrid,
tracks: I,
index: usize,
- lines: &'grid [Line],
+ lines: L,
is_max_index: bool,
line_stroke_at_track: F,
) -> impl Iterator<Item = LineSegment> + 'grid
@@ -135,6 +135,8 @@ where
+ 'grid,
I: IntoIterator<Item = (usize, Abs)>,
I::IntoIter: 'grid,
+ L: IntoIterator<Item = &'grid Line>,
+ L::IntoIter: Clone + 'grid,
{
// The segment currently being drawn.
//
@@ -162,7 +164,7 @@ where
// Note that the maximum index is always an odd number when there's gutter,
// so we must check for it to ensure we don't give it the same treatment as
// a line before a gutter track.
- let expected_line_position = if grid.has_gutter && index % 2 == 1 && !is_max_index {
+ let expected_line_position = if grid.is_gutter_track(index) && !is_max_index {
LinePosition::After
} else {
LinePosition::Before
@@ -194,6 +196,7 @@ where
// interrupt the current segment one last time, to ensure the final segment
// is always interrupted and yielded, if it wasn't interrupted earlier.
let mut tracks = tracks.into_iter();
+ let lines = lines.into_iter();
std::iter::from_fn(move || {
// Each time this closure runs, we advance the track iterator as much
// as possible before returning because the current segment was
@@ -205,7 +208,7 @@ where
// strokes of each user-specified line (with priority to the
// user-specified line specified last).
let mut line_strokes = lines
- .iter()
+ .clone()
.filter(|line| {
line.position == expected_line_position
&& line
@@ -332,45 +335,48 @@ pub(super) fn vline_stroke_at_row(
y: usize,
stroke: Option<Option<Arc<Stroke<Abs>>>>,
) -> Option<(Arc<Stroke<Abs>>, StrokePriority)> {
+ // When the vline isn't at the border, we need to check if a colspan would
+ // be present between columns 'x' and 'x-1' at row 'y', and thus overlap
+ // with the line.
+ // To do so, we analyze the cell right after this vline. If it is merged
+ // with a cell before this line (parent.x < x) which is at this row or
+ // above it (parent.y <= y, which is checked by
+ // 'effective_parent_cell_position'), this means it would overlap with the
+ // vline, so the vline must not be drawn at this row.
if x != 0 && x != grid.cols.len() {
- // When the vline isn't at the border, we need to check if a colspan would
- // be present between columns 'x' and 'x-1' at row 'y', and thus overlap
- // with the line.
- // To do so, we analyze the cell right after this vline. If it is merged
- // with a cell before this line (parent_x < x) which is at this row or
- // above it (parent_y <= y), this means it would overlap with the vline,
- // so the vline must not be drawn at this row.
- let first_adjacent_cell = if grid.has_gutter {
- // Skip the gutters, if x or y represent gutter tracks.
- // We would then analyze the cell one column after (if at a gutter
- // column), and/or one row below (if at a gutter row), in order to
- // check if it would be merged with a cell before the vline.
- (x + x % 2, y + y % 2)
- } else {
- (x, y)
- };
- let Axes { x: parent_x, y: parent_y } = grid
- .parent_cell_position(first_adjacent_cell.0, first_adjacent_cell.1)
- .unwrap();
-
- if parent_x < x && parent_y <= y {
- // There is a colspan cell going through this vline's position,
- // so don't draw it here.
- return None;
+ // Use 'effective_parent_cell_position' to skip the gutters, if x or y
+ // represent gutter tracks.
+ // We would then analyze the cell one column after (if at a gutter
+ // column), and/or one row below (if at a gutter row), in order to
+ // check if it would be merged with a cell before the vline.
+ if let Some(parent) = grid.effective_parent_cell_position(x, y) {
+ if parent.x < x {
+ // There is a colspan cell going through this vline's position,
+ // so don't draw it here.
+ return None;
+ }
}
}
let (left_cell_stroke, left_cell_prioritized) = x
.checked_sub(1)
- .and_then(|left_x| grid.parent_cell(left_x, y))
- .map(|left_cell| {
+ .and_then(|left_x| {
+ // Let's find the parent cell of the position before us, in order
+ // to take its right stroke, even with gutter before us.
+ grid.effective_parent_cell_position(left_x, y)
+ })
+ .map(|parent| {
+ let left_cell = grid.cell(parent.x, parent.y).unwrap();
(left_cell.stroke.right.clone(), left_cell.stroke_overridden.right)
})
.unwrap_or((None, false));
let (right_cell_stroke, right_cell_prioritized) = if x < grid.cols.len() {
- grid.parent_cell(x, y)
- .map(|right_cell| {
+ // Let's find the parent cell of the position after us, in order
+ // to take its left stroke, even with gutter after us.
+ grid.effective_parent_cell_position(x, y)
+ .map(|parent| {
+ let right_cell = grid.cell(parent.x, parent.y).unwrap();
(right_cell.stroke.left.clone(), right_cell.stroke_overridden.left)
})
.unwrap_or((None, false))
@@ -416,6 +422,12 @@ pub(super) fn vline_stroke_at_row(
/// while `Some(None)` means specified to remove any stroke at this position).
/// Also returns the stroke's drawing priority, which depends on its source.
///
+/// The `local_top_y` parameter indicates which row is effectively on top of
+/// this hline at the current region. This is `None` if the hline is above the
+/// first row in the region, for instance. The `in_last_region` parameter
+/// indicates whether this is the last region of the table. If not and this is
+/// a line at the bottom border, the bottom border's line gains priority.
+///
/// If the one (when at the border) or two (otherwise) cells above and below
/// the hline have bottom and top stroke overrides, respectively, then the
/// cells' stroke overrides are folded together with the hline's stroke (with
@@ -428,58 +440,105 @@ pub(super) fn vline_stroke_at_row(
///
/// The priority associated with the returned stroke follows the rules
/// described in the docs for `generate_line_segment`.
+///
+/// The rows argument is needed to know which rows are effectively present in
+/// the current region, in order to avoid unnecessary hline splitting when a
+/// rowspan's previous rows are either in a previous region or empty (and thus
+/// wouldn't overlap with the hline, since its first row in the current region
+/// is below the hline).
+///
+/// This function assumes columns are sorted by increasing `x`, and rows are
+/// sorted by increasing `y`.
pub(super) fn hline_stroke_at_column(
grid: &CellGrid,
+ rows: &[RowPiece],
+ local_top_y: Option<usize>,
+ in_last_region: bool,
y: usize,
x: usize,
stroke: Option<Option<Arc<Stroke<Abs>>>>,
) -> Option<(Arc<Stroke<Abs>>, StrokePriority)> {
- // There are no rowspans yet, so no need to add a check here. The line will
- // always be drawn, if it has a stroke.
- let cell_x = if grid.has_gutter {
- // Skip the gutter column this hline is in.
- // This is because positions above and below it, even if gutter, could
- // be part of a colspan, so we have to check the following cell.
- // However, this is only valid if we're not in a gutter row.
- x + x % 2
- } else {
- x
- };
+ // When the hline isn't at the border, we need to check if a rowspan
+ // would be present between rows 'y' and 'y-1' at column 'x', and thus
+ // overlap with the line.
+ // To do so, we analyze the cell right below this hline. If it is
+ // merged with a cell above this line (parent.y < y) which is at this
+ // column or before it (parent.x <= x, which is checked by
+ // 'effective_parent_cell_position'), this means it would overlap with the
+ // hline, so the hline must not be drawn at this column.
+ if y != 0 && y != grid.rows.len() {
+ // Use 'effective_parent_cell_position' to skip the gutters, if x or y
+ // represent gutter tracks.
+ // We would then analyze the cell one column after (if at a gutter
+ // column), and/or one row below (if at a gutter row), in order to
+ // check if it would be merged with a cell before the hline.
+ if let Some(parent) = grid.effective_parent_cell_position(x, y) {
+ if parent.y < y {
+ // Get the first 'y' spanned by the possible rowspan in this region.
+ // The 'parent.y' row and any other spanned rows above 'y' could be
+ // missing from this region, which could have lead the check above
+ // to be triggered, even though there is no spanned row above the
+ // hline in the final layout of this region, and thus no overlap
+ // with the hline, allowing it to be drawn regardless of the
+ // theoretical presence of a rowspan going across its position.
+ let local_parent_y = rows
+ .iter()
+ .find(|row| row.y >= parent.y)
+ .map(|row| row.y)
+ .unwrap_or(y);
- let (top_cell_stroke, top_cell_prioritized) = y
- .checked_sub(1)
+ if local_parent_y < y {
+ // There is a rowspan cell going through this hline's
+ // position, so don't draw it here.
+ return None;
+ }
+ }
+ }
+ }
+
+ // When the hline is at the top of the region and this isn't the first
+ // region, fold with the top stroke of the topmost cell at this column,
+ // that is, the top border.
+ let use_top_border_stroke = local_top_y.is_none() && y != 0;
+ let (top_cell_stroke, top_cell_prioritized) = local_top_y
+ .or(use_top_border_stroke.then_some(0))
.and_then(|top_y| {
// Let's find the parent cell of the position above us, in order
// to take its bottom stroke, even when we're below gutter.
- grid.parent_cell_position(cell_x, top_y)
- })
- .filter(|Axes { x: parent_x, .. }| {
- // Only use the stroke of the cell above us but one column to the
- // right if it is merged with a cell before this line's column.
- // If the position above us is a simple non-merged cell, or the
- // parent of a colspan, this will also evaluate to true.
- parent_x <= &x
+ grid.effective_parent_cell_position(x, top_y)
})
- .map(|Axes { x: parent_x, y: parent_y }| {
- let top_cell = grid.cell(parent_x, parent_y).unwrap();
- (top_cell.stroke.bottom.clone(), top_cell.stroke_overridden.bottom)
+ .map(|parent| {
+ let top_cell = grid.cell(parent.x, parent.y).unwrap();
+ if use_top_border_stroke {
+ (top_cell.stroke.top.clone(), top_cell.stroke_overridden.top)
+ } else {
+ (top_cell.stroke.bottom.clone(), top_cell.stroke_overridden.bottom)
+ }
})
.unwrap_or((None, false));
- let (bottom_cell_stroke, bottom_cell_prioritized) = if y < grid.rows.len() {
+ // Use the bottom border stroke with priority if we're not in the last
+ // region, we have the last index, and (as a failsafe) we don't have the
+ // last row of cells above us.
+ let use_bottom_border_stroke = !in_last_region
+ && local_top_y.map_or(true, |top_y| top_y + 1 != grid.rows.len())
+ && y == grid.rows.len();
+ let bottom_y =
+ if use_bottom_border_stroke { grid.rows.len().saturating_sub(1) } else { y };
+ let (bottom_cell_stroke, bottom_cell_prioritized) = if bottom_y < grid.rows.len() {
// Let's find the parent cell of the position below us, in order
// to take its top stroke, even when we're above gutter.
- grid.parent_cell_position(cell_x, y)
- .filter(|Axes { x: parent_x, .. }| {
- // Only use the stroke of the cell below us but one column to the
- // right if it is merged with a cell before this line's column.
- // If the position below us is a simple non-merged cell, or the
- // parent of a colspan, this will also evaluate to true.
- parent_x <= &x
- })
- .map(|Axes { x: parent_x, y: parent_y }| {
- let bottom_cell = grid.cell(parent_x, parent_y).unwrap();
- (bottom_cell.stroke.top.clone(), bottom_cell.stroke_overridden.top)
+ grid.effective_parent_cell_position(x, bottom_y)
+ .map(|parent| {
+ let bottom_cell = grid.cell(parent.x, parent.y).unwrap();
+ if use_bottom_border_stroke {
+ (
+ bottom_cell.stroke.bottom.clone(),
+ bottom_cell.stroke_overridden.bottom,
+ )
+ } else {
+ (bottom_cell.stroke.top.clone(), bottom_cell.stroke_overridden.top)
+ }
})
.unwrap_or((None, false))
} else {
@@ -496,11 +555,17 @@ pub(super) fn hline_stroke_at_column(
};
let (prioritized_cell_stroke, deprioritized_cell_stroke) =
- if top_cell_prioritized && !bottom_cell_prioritized {
+ if !use_bottom_border_stroke
+ && (use_top_border_stroke || top_cell_prioritized && !bottom_cell_prioritized)
+ {
+ // Top border must always be prioritized, even if it did not
+ // request for that explicitly.
(top_cell_stroke, bottom_cell_stroke)
} else {
// When both cells' strokes have the same priority, we default to
// prioritizing the bottom cell's top stroke.
+ // Additionally, the bottom border cell's stroke always has
+ // priority.
(bottom_cell_stroke, top_cell_stroke)
};
@@ -524,7 +589,7 @@ mod test {
use super::super::layout::{Entry, RowPiece};
use super::*;
use crate::foundations::Content;
- use crate::layout::{Cell, Sides, Sizing};
+ use crate::layout::{Axes, Cell, Sides, Sizing};
use crate::util::NonZeroExt;
fn sample_cell() -> Cell {
@@ -532,43 +597,47 @@ mod test {
body: Content::default(),
fill: None,
colspan: NonZeroUsize::ONE,
+ rowspan: NonZeroUsize::ONE,
stroke: Sides::splat(Some(Arc::new(Stroke::default()))),
stroke_overridden: Sides::splat(false),
+ breakable: true,
}
}
- fn cell_with_colspan(colspan: usize) -> Cell {
+ fn cell_with_colspan_rowspan(colspan: usize, rowspan: usize) -> Cell {
Cell {
body: Content::default(),
fill: None,
colspan: NonZeroUsize::try_from(colspan).unwrap(),
+ rowspan: NonZeroUsize::try_from(rowspan).unwrap(),
stroke: Sides::splat(Some(Arc::new(Stroke::default()))),
stroke_overridden: Sides::splat(false),
+ breakable: true,
}
}
- fn sample_grid(gutters: bool) -> CellGrid {
+ fn sample_grid_for_vlines(gutters: bool) -> CellGrid {
const COLS: usize = 4;
const ROWS: usize = 6;
let entries = vec![
// row 0
Entry::Cell(sample_cell()),
Entry::Cell(sample_cell()),
- Entry::Cell(cell_with_colspan(2)),
+ Entry::Cell(cell_with_colspan_rowspan(2, 1)),
Entry::Merged { parent: 2 },
// row 1
Entry::Cell(sample_cell()),
- Entry::Cell(cell_with_colspan(3)),
+ Entry::Cell(cell_with_colspan_rowspan(3, 1)),
Entry::Merged { parent: 5 },
Entry::Merged { parent: 5 },
// row 2
Entry::Merged { parent: 4 },
Entry::Cell(sample_cell()),
- Entry::Cell(cell_with_colspan(2)),
+ Entry::Cell(cell_with_colspan_rowspan(2, 1)),
Entry::Merged { parent: 10 },
// row 3
Entry::Cell(sample_cell()),
- Entry::Cell(cell_with_colspan(3)),
+ Entry::Cell(cell_with_colspan_rowspan(3, 2)),
Entry::Merged { parent: 13 },
Entry::Merged { parent: 13 },
// row 4
@@ -579,7 +648,7 @@ mod test {
// row 5
Entry::Cell(sample_cell()),
Entry::Cell(sample_cell()),
- Entry::Cell(cell_with_colspan(2)),
+ Entry::Cell(cell_with_colspan_rowspan(2, 1)),
Entry::Merged { parent: 22 },
];
CellGrid::new_internal(
@@ -598,7 +667,7 @@ mod test {
#[test]
fn test_vline_splitting_without_gutter() {
let stroke = Arc::new(Stroke::default());
- let grid = sample_grid(false);
+ let grid = sample_grid_for_vlines(false);
let rows = &[
RowPiece { height: Abs::pt(1.0), y: 0 },
RowPiece { height: Abs::pt(2.0), y: 1 },
@@ -670,7 +739,7 @@ mod test {
#[test]
fn test_vline_splitting_with_gutter_and_per_cell_stroke() {
let stroke = Arc::new(Stroke::default());
- let grid = sample_grid(true);
+ let grid = sample_grid_for_vlines(true);
let rows = &[
RowPiece { height: Abs::pt(1.0), y: 0 },
RowPiece { height: Abs::pt(2.0), y: 1 },
@@ -694,16 +763,11 @@ mod test {
length: Abs::pt(1.),
priority: StrokePriority::GridStroke,
},
+ // Covers the rowspan between (original) rows 1 and 2
LineSegment {
stroke: stroke.clone(),
offset: Abs::pt(1. + 2.),
- length: Abs::pt(4.),
- priority: StrokePriority::GridStroke,
- },
- LineSegment {
- stroke: stroke.clone(),
- offset: Abs::pt(1. + 2. + 4. + 8.),
- length: Abs::pt(16.),
+ length: Abs::pt(4. + 8. + 16.),
priority: StrokePriority::GridStroke,
},
LineSegment {
@@ -735,16 +799,11 @@ mod test {
length: Abs::pt(1.),
priority: StrokePriority::GridStroke,
},
+ // Covers the rowspan between (original) rows 1 and 2
LineSegment {
stroke: stroke.clone(),
offset: Abs::pt(1. + 2.),
- length: Abs::pt(4.),
- priority: StrokePriority::GridStroke,
- },
- LineSegment {
- stroke: stroke.clone(),
- offset: Abs::pt(1. + 2. + 4. + 8.),
- length: Abs::pt(16.),
+ length: Abs::pt(4. + 8. + 16.),
priority: StrokePriority::GridStroke,
},
LineSegment {
@@ -787,16 +846,11 @@ mod test {
length: Abs::pt(16.),
priority: StrokePriority::GridStroke,
},
+ // Covers the rowspan between (original) rows 3 and 4
LineSegment {
stroke: stroke.clone(),
offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.),
- length: Abs::pt(64.),
- priority: StrokePriority::GridStroke,
- },
- LineSegment {
- stroke: stroke.clone(),
- offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128.),
- length: Abs::pt(256.),
+ length: Abs::pt(64. + 128. + 256.),
priority: StrokePriority::GridStroke,
},
LineSegment {
@@ -880,16 +934,11 @@ mod test {
length: Abs::pt(16.),
priority: StrokePriority::GridStroke,
},
+ // Covers the rowspan between (original) rows 3 and 4
LineSegment {
stroke: stroke.clone(),
offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.),
- length: Abs::pt(64.),
- priority: StrokePriority::GridStroke,
- },
- LineSegment {
- stroke: stroke.clone(),
- offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128.),
- length: Abs::pt(256.),
+ length: Abs::pt(64. + 128. + 256.),
priority: StrokePriority::GridStroke,
},
LineSegment {
@@ -922,7 +971,7 @@ mod test {
#[test]
fn test_vline_splitting_with_gutter_and_explicit_vlines() {
let stroke = Arc::new(Stroke::default());
- let grid = sample_grid(true);
+ let grid = sample_grid_for_vlines(true);
let rows = &[
RowPiece { height: Abs::pt(1.0), y: 0 },
RowPiece { height: Abs::pt(2.0), y: 1 },
@@ -1102,4 +1151,409 @@ mod test {
);
}
}
+
+ fn sample_grid_for_hlines(gutters: bool) -> CellGrid {
+ const COLS: usize = 4;
+ const ROWS: usize = 9;
+ let entries = vec![
+ // row 0
+ Entry::Cell(cell_with_colspan_rowspan(1, 2)),
+ Entry::Cell(sample_cell()),
+ Entry::Cell(cell_with_colspan_rowspan(2, 2)),
+ Entry::Merged { parent: 2 },
+ // row 1
+ Entry::Merged { parent: 0 },
+ Entry::Cell(sample_cell()),
+ Entry::Merged { parent: 2 },
+ Entry::Merged { parent: 2 },
+ // row 2
+ Entry::Cell(sample_cell()),
+ Entry::Cell(sample_cell()),
+ Entry::Cell(sample_cell()),
+ Entry::Cell(sample_cell()),
+ // row 3
+ Entry::Cell(cell_with_colspan_rowspan(4, 2)),
+ Entry::Merged { parent: 12 },
+ Entry::Merged { parent: 12 },
+ Entry::Merged { parent: 12 },
+ // row 4
+ Entry::Merged { parent: 12 },
+ Entry::Merged { parent: 12 },
+ Entry::Merged { parent: 12 },
+ Entry::Merged { parent: 12 },
+ // row 5
+ Entry::Cell(sample_cell()),
+ Entry::Cell(cell_with_colspan_rowspan(1, 2)),
+ Entry::Cell(cell_with_colspan_rowspan(2, 1)),
+ Entry::Merged { parent: 22 },
+ // row 6
+ Entry::Cell(sample_cell()),
+ Entry::Merged { parent: 21 },
+ Entry::Cell(sample_cell()),
+ Entry::Cell(sample_cell()),
+ // row 7 (adjacent rowspans covering the whole row)
+ Entry::Cell(cell_with_colspan_rowspan(2, 2)),
+ Entry::Merged { parent: 28 },
+ Entry::Cell(cell_with_colspan_rowspan(2, 2)),
+ Entry::Merged { parent: 30 },
+ // row 8
+ Entry::Merged { parent: 28 },
+ Entry::Merged { parent: 28 },
+ Entry::Merged { parent: 30 },
+ Entry::Merged { parent: 30 },
+ ];
+ CellGrid::new_internal(
+ Axes::with_x(&[Sizing::Auto; COLS]),
+ if gutters {
+ Axes::new(&[Sizing::Auto; COLS - 1], &[Sizing::Auto; ROWS - 1])
+ } else {
+ Axes::default()
+ },
+ vec![],
+ vec![],
+ entries,
+ )
+ }
+
+ #[test]
+ fn test_hline_splitting_without_gutter() {
+ let stroke = Arc::new(Stroke::default());
+ let grid = sample_grid_for_hlines(false);
+ let columns = &[Abs::pt(1.), Abs::pt(2.), Abs::pt(4.), Abs::pt(8.)];
+ // Assume all rows would be drawn in the same region, and are available.
+ let rows = grid
+ .rows
+ .iter()
+ .enumerate()
+ .map(|(y, _)| RowPiece { height: Abs::pt(f64::from(2u32.pow(y as u32))), y })
+ .collect::<Vec<_>>();
+ let expected_hline_splits = &[
+ // top border
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8.),
+ priority: StrokePriority::GridStroke,
+ }],
+ // interrupted a few times by rowspans
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1.),
+ length: Abs::pt(2.),
+ priority: StrokePriority::GridStroke,
+ }],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8.),
+ priority: StrokePriority::GridStroke,
+ }],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8.),
+ priority: StrokePriority::GridStroke,
+ }],
+ // interrupted every time by rowspans
+ vec![],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8.),
+ priority: StrokePriority::GridStroke,
+ }],
+ // interrupted once by rowspan
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1.),
+ priority: StrokePriority::GridStroke,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2.),
+ length: Abs::pt(4. + 8.),
+ priority: StrokePriority::GridStroke,
+ },
+ ],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8.),
+ priority: StrokePriority::GridStroke,
+ }],
+ // interrupted every time by successive rowspans
+ vec![],
+ // bottom border
+ vec![LineSegment {
+ stroke,
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8.),
+ priority: StrokePriority::GridStroke,
+ }],
+ ];
+ for (y, expected_splits) in expected_hline_splits.iter().enumerate() {
+ let tracks = columns.iter().copied().enumerate();
+ assert_eq!(
+ expected_splits,
+ &generate_line_segments(
+ &grid,
+ tracks,
+ y,
+ &[],
+ y == grid.rows.len(),
+ |grid, y, x, stroke| hline_stroke_at_column(
+ grid,
+ &rows,
+ y.checked_sub(1),
+ true,
+ y,
+ x,
+ stroke
+ )
+ )
+ .collect::<Vec<_>>(),
+ );
+ }
+ }
+
+ #[test]
+ fn test_hline_splitting_with_gutter_and_explicit_hlines() {
+ let stroke = Arc::new(Stroke::default());
+ let grid = sample_grid_for_hlines(true);
+ let columns = &[
+ Abs::pt(1.0),
+ Abs::pt(2.0),
+ Abs::pt(4.0),
+ Abs::pt(8.0),
+ Abs::pt(16.0),
+ Abs::pt(32.0),
+ Abs::pt(64.0),
+ ];
+ // Assume all rows would be drawn in the same region, and are available.
+ let rows = grid
+ .rows
+ .iter()
+ .enumerate()
+ .map(|(y, _)| RowPiece { height: Abs::pt(f64::from(2u32.pow(y as u32))), y })
+ .collect::<Vec<_>>();
+ let expected_hline_splits = &[
+ // top border
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // gutter line below
+ // interrupted a few times by rowspans
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1.),
+ length: Abs::pt(2. + 4. + 8.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // interrupted a few times by rowspans
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1.),
+ length: Abs::pt(2. + 4. + 8.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // gutter line below
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // gutter line below
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // gutter line below
+ // interrupted every time by rowspans
+ vec![],
+ // interrupted every time by rowspans
+ vec![],
+ // gutter line below
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // gutter line below
+ // interrupted once by rowspan
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4.),
+ length: Abs::pt(8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ ],
+ // interrupted once by rowspan
+ vec![
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4.),
+ length: Abs::pt(8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ },
+ ],
+ // gutter line below
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // gutter line below
+ // there are two consecutive rowspans, but the gutter column
+ // between them is free.
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4.),
+ length: Abs::pt(8.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(1. + 2. + 4.),
+ length: Abs::pt(8.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ // bottom border
+ vec![LineSegment {
+ stroke: stroke.clone(),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64.),
+ priority: StrokePriority::ExplicitLine,
+ }],
+ ];
+ for (y, expected_splits) in expected_hline_splits.iter().enumerate() {
+ let tracks = columns.iter().copied().enumerate();
+ assert_eq!(
+ expected_splits,
+ &generate_line_segments(
+ &grid,
+ tracks,
+ y,
+ &[
+ Line {
+ index: y,
+ start: 0,
+ end: None,
+ stroke: Some(stroke.clone()),
+ position: LinePosition::Before
+ },
+ Line {
+ index: y,
+ start: 0,
+ end: None,
+ stroke: Some(stroke.clone()),
+ position: LinePosition::After
+ },
+ ],
+ y == grid.rows.len(),
+ |grid, y, x, stroke| hline_stroke_at_column(
+ grid,
+ &rows,
+ y.checked_sub(1),
+ true,
+ y,
+ x,
+ stroke
+ )
+ )
+ .collect::<Vec<_>>(),
+ );
+ }
+ }
+
+ #[test]
+ fn test_hline_splitting_considers_absent_rows() {
+ let grid = sample_grid_for_hlines(false);
+ let columns = &[Abs::pt(1.), Abs::pt(2.), Abs::pt(4.), Abs::pt(8.)];
+ // Assume row 3 is absent (even though there's a rowspan between rows
+ // 3 and 4)
+ // This can happen if it is an auto row which turns out to be fully
+ // empty.
+ let rows = grid
+ .rows
+ .iter()
+ .enumerate()
+ .filter(|(y, _)| *y != 3)
+ .map(|(y, _)| RowPiece { height: Abs::pt(f64::from(2u32.pow(y as u32))), y })
+ .collect::<Vec<_>>();
+
+ // Hline above row 4 is no longer blocked, since the rowspan is now
+ // effectively spanning just one row (at least, visibly).
+ assert_eq!(
+ &vec![LineSegment {
+ stroke: Arc::new(Stroke::default()),
+ offset: Abs::pt(0.),
+ length: Abs::pt(1. + 2. + 4. + 8.),
+ priority: StrokePriority::GridStroke
+ }],
+ &generate_line_segments(
+ &grid,
+ columns.iter().copied().enumerate(),
+ 4,
+ &[],
+ 4 == grid.rows.len(),
+ |grid, y, x, stroke| hline_stroke_at_column(
+ grid,
+ &rows,
+ if y == 4 { Some(2) } else { y.checked_sub(1) },
+ true,
+ y,
+ x,
+ stroke
+ )
+ )
+ .collect::<Vec<_>>()
+ );
+ }
}
diff --git a/crates/typst/src/layout/grid/mod.rs b/crates/typst/src/layout/grid/mod.rs
index fc884e80..6675f3e0 100644
--- a/crates/typst/src/layout/grid/mod.rs
+++ b/crates/typst/src/layout/grid/mod.rs
@@ -1,5 +1,6 @@
mod layout;
mod lines;
+mod rowspans;
pub use self::layout::{Cell, CellGrid, Celled, GridItem, GridLayouter, ResolvableCell};
pub use self::lines::LinePosition;
@@ -644,6 +645,10 @@ pub struct GridCell {
#[default(NonZeroUsize::ONE)]
pub colspan: NonZeroUsize,
+ /// The amount of rows spanned by this cell.
+ #[default(NonZeroUsize::ONE)]
+ pub rowspan: NonZeroUsize,
+
/// The cell's fill override.
pub fill: Smart<Option<Paint>>,
@@ -657,6 +662,12 @@ pub struct GridCell {
#[resolve]
#[fold]
pub stroke: Sides<Option<Option<Arc<Stroke>>>>,
+
+ /// Whether rows spanned by this cell can be placed in different pages.
+ /// When equal to `{auto}`, a cell spanning only fixed-size rows is
+ /// unbreakable, while a cell spanning at least one `{auto}`-sized row is
+ /// breakable.
+ pub breakable: Smart<bool>,
}
cast! {
@@ -679,10 +690,13 @@ impl ResolvableCell for Packed<GridCell> {
align: Smart<Alignment>,
inset: Sides<Option<Rel<Length>>>,
stroke: Sides<Option<Option<Arc<Stroke<Abs>>>>>,
+ breakable: bool,
styles: StyleChain,
) -> Cell {
let cell = &mut *self;
let colspan = cell.colspan(styles);
+ let rowspan = cell.rowspan(styles);
+ let breakable = cell.breakable(styles).unwrap_or(breakable);
let fill = cell.fill(styles).unwrap_or_else(|| fill.clone());
let cell_stroke = cell.stroke(styles);
@@ -727,12 +741,15 @@ impl ResolvableCell for Packed<GridCell> {
}))
}),
);
+ cell.push_breakable(Smart::Custom(breakable));
Cell {
body: self.pack(),
fill,
colspan,
+ rowspan,
stroke,
stroke_overridden,
+ breakable,
}
}
@@ -748,6 +765,10 @@ impl ResolvableCell for Packed<GridCell> {
(**self).colspan(styles)
}
+ fn rowspan(&self, styles: StyleChain) -> NonZeroUsize {
+ (**self).rowspan(styles)
+ }
+
fn span(&self) -> Span {
Packed::span(self)
}
diff --git a/crates/typst/src/layout/grid/rowspans.rs b/crates/typst/src/layout/grid/rowspans.rs
new file mode 100644
index 00000000..be63da5c
--- /dev/null
+++ b/crates/typst/src/layout/grid/rowspans.rs
@@ -0,0 +1,864 @@
+use crate::diag::SourceResult;
+use crate::engine::Engine;
+use crate::foundations::Resolve;
+use crate::layout::{
+ Abs, Axes, Cell, Frame, GridLayouter, LayoutMultiple, Point, Regions, Size, Sizing,
+};
+use crate::util::MaybeReverseIter;
+
+use super::layout::{points, Row};
+
+/// All information needed to layout a single rowspan.
+pub(super) struct Rowspan {
+ // First column of this rowspan.
+ pub(super) x: usize,
+ // First row of this rowspan.
+ pub(super) y: usize,
+ // Amount of rows spanned by the cell at (x, y).
+ pub(super) rowspan: usize,
+ /// The horizontal offset of this rowspan in all regions.
+ pub(super) dx: Abs,
+ /// The vertical offset of this rowspan in the first region.
+ pub(super) dy: Abs,
+ /// The index of the first region this rowspan appears in.
+ pub(super) first_region: usize,
+ /// The full height in the first region this rowspan appears in, for
+ /// relative sizing.
+ pub(super) region_full: Abs,
+ /// The vertical space available for this rowspan in each region.
+ pub(super) heights: Vec<Abs>,
+}
+
+/// The output of the simulation of an unbreakable row group.
+#[derive(Default)]
+pub(super) struct UnbreakableRowGroup {
+ /// The rows in this group of unbreakable rows.
+ /// Includes their indices and their predicted heights.
+ pub(super) rows: Vec<(usize, Abs)>,
+ /// The total height of this row group.
+ pub(super) height: Abs,
+}
+
+/// Data used to measure a cell in an auto row.
+pub(super) struct CellMeasurementData<'layouter> {
+ /// The available width for the cell across all regions.
+ pub(super) width: Abs,
+ /// The available height for the cell in its first region.
+ pub(super) height: Abs,
+ /// The backlog of heights available for the cell in later regions.
+ /// When this is `None`, the `custom_backlog` field should be used instead.
+ pub(super) backlog: Option<&'layouter [Abs]>,
+ /// If the backlog needs to be built from scratch instead of reusing the
+ /// one at the current region, which is the case of a multi-region rowspan
+ /// (needs to join its backlog of already laid out heights with the current
+ /// backlog), then this vector will store the new backlog.
+ pub(super) custom_backlog: Vec<Abs>,
+ /// The full height of the first region of the cell.
+ pub(super) full: Abs,
+ /// The total height of previous rows spanned by the cell in the current
+ /// region (so far).
+ pub(super) height_in_this_region: Abs,
+ /// The amount of previous regions spanned by the cell.
+ /// They are skipped for measurement purposes.
+ pub(super) frames_in_previous_regions: usize,
+}
+
+impl<'a> GridLayouter<'a> {
+ /// Layout a rowspan over the already finished regions, plus the current
+ /// region, if it wasn't finished yet (because we're being called from
+ /// `finish_region`, but note that this function is also called once after
+ /// all regions are finished, in which case `current_region` is `None`).
+ ///
+ /// We need to do this only once we already know the heights of all
+ /// spanned rows, which is only possible after laying out the last row
+ /// spanned by the rowspan (or some row immediately after the last one).
+ pub(super) fn layout_rowspan(
+ &mut self,
+ rowspan_data: Rowspan,
+ current_region: Option<&mut Frame>,
+ engine: &mut Engine,
+ ) -> SourceResult<()> {
+ let Rowspan {
+ x, y, dx, dy, first_region, region_full, heights, ..
+ } = rowspan_data;
+ let [first_height, backlog @ ..] = heights.as_slice() else {
+ // Nothing to layout.
+ return Ok(());
+ };
+ let first_column = self.rcols[x];
+ let cell = self.grid.cell(x, y).unwrap();
+ let width = self.cell_spanned_width(cell, x);
+ let dx = if self.is_rtl { dx - width + first_column } else { dx };
+
+ // Prepare regions.
+ let size = Size::new(width, *first_height);
+ let mut pod = Regions::one(size, Axes::splat(true));
+ pod.full = region_full;
+ pod.backlog = backlog;
+
+ // Push the layouted frames directly into the finished frames.
+ // At first, we draw the rowspan starting at its expected offset
+ // in the first region.
+ let mut pos = Point::new(dx, dy);
+ let fragment = cell.layout(engine, self.styles, pod)?;
+ for (finished, frame) in self
+ .finished
+ .iter_mut()
+ .chain(current_region.into_iter())
+ .skip(first_region)
+ .zip(fragment)
+ {
+ finished.push_frame(pos, frame);
+
+ // From the second region onwards, the rowspan's continuation
+ // starts at the very top.
+ pos.y = Abs::zero();
+ }
+
+ Ok(())
+ }
+
+ /// Checks if a row contains the beginning of one or more rowspan cells.
+ /// If so, adds them to the rowspans vector.
+ pub(super) fn check_for_rowspans(&mut self, y: usize) {
+ // We will compute the horizontal offset of each rowspan in advance.
+ // For that reason, we must reverse the column order when using RTL.
+ let offsets = points(self.rcols.iter().copied().rev_if(self.is_rtl));
+ for (x, dx) in (0..self.rcols.len()).rev_if(self.is_rtl).zip(offsets) {
+ let Some(cell) = self.grid.cell(x, y) else {
+ continue;
+ };
+ let rowspan = self.grid.effective_rowspan_of_cell(cell);
+ if rowspan > 1 {
+ // Rowspan detected. We will lay it out later.
+ self.rowspans.push(Rowspan {
+ x,
+ y,
+ rowspan,
+ dx,
+ // The four fields below will be updated in 'finish_region'.
+ dy: Abs::zero(),
+ first_region: usize::MAX,
+ region_full: Abs::zero(),
+ heights: vec![],
+ });
+ }
+ }
+ }
+
+ /// Checks if the upcoming rows will be grouped together under an
+ /// unbreakable row group, and, if so, advances regions until there is
+ /// enough space for them. This can be needed, for example, if there's an
+ /// unbreakable rowspan crossing those rows.
+ pub(super) fn check_for_unbreakable_rows(
+ &mut self,
+ current_row: usize,
+ engine: &mut Engine,
+ ) -> SourceResult<()> {
+ if self.unbreakable_rows_left == 0 {
+ let row_group =
+ self.simulate_unbreakable_row_group(current_row, &self.regions, engine)?;
+
+ // Skip to fitting region.
+ while !self.regions.size.y.fits(row_group.height) && !self.regions.in_last() {
+ self.finish_region(engine)?;
+ }
+ self.unbreakable_rows_left = row_group.rows.len();
+ }
+
+ Ok(())
+ }
+
+ /// Simulates a group of unbreakable rows, starting with the index of the
+ /// first row in the group. Keeps adding rows to the group until none have
+ /// unbreakable cells in common.
+ ///
+ /// This is used to figure out how much height the next unbreakable row
+ /// group (if any) needs.
+ pub(super) fn simulate_unbreakable_row_group(
+ &self,
+ first_row: usize,
+ regions: &Regions<'_>,
+ engine: &mut Engine,
+ ) -> SourceResult<UnbreakableRowGroup> {
+ let mut row_group = UnbreakableRowGroup::default();
+ let mut unbreakable_rows_left = 0;
+ for (y, row) in self.grid.rows.iter().enumerate().skip(first_row) {
+ let additional_unbreakable_rows = self.check_for_unbreakable_cells(y);
+ unbreakable_rows_left =
+ unbreakable_rows_left.max(additional_unbreakable_rows);
+ if unbreakable_rows_left == 0 {
+ // This check is in case the first row does not have any
+ // unbreakable cells. Therefore, no unbreakable row group
+ // is formed.
+ break;
+ }
+ let height = match row {
+ Sizing::Rel(v) => v.resolve(self.styles).relative_to(regions.base().y),
+
+ // No need to pass the regions to the auto row, since
+ // unbreakable auto rows are always measured with infinite
+ // height, ignore backlog, and do not invoke the rowspan
+ // simulation procedure at all.
+ Sizing::Auto => self
+ .measure_auto_row(
+ engine,
+ y,
+ false,
+ unbreakable_rows_left,
+ Some(&row_group),
+ )?
+ .unwrap()
+ .first()
+ .copied()
+ .unwrap_or_else(Abs::zero),
+ // Fractional rows don't matter when calculating the space
+ // needed for unbreakable rows
+ Sizing::Fr(_) => Abs::zero(),
+ };
+ row_group.height += height;
+ row_group.rows.push((y, height));
+ unbreakable_rows_left -= 1;
+ if unbreakable_rows_left == 0 {
+ // This second check is necessary so we can tell distinct
+ // but consecutive unbreakable row groups apart. If the
+ // unbreakable row group ended at this row, we stop before
+ // checking the next one.
+ break;
+ }
+ }
+
+ Ok(row_group)
+ }
+
+ /// Checks if one or more of the cells at the given row are unbreakable.
+ /// If so, returns the largest rowspan among the unbreakable cells;
+ /// the spanned rows must, as a result, be laid out in the same region.
+ pub(super) fn check_for_unbreakable_cells(&self, y: usize) -> usize {
+ (0..self.grid.cols.len())
+ .filter_map(|x| self.grid.cell(x, y))
+ .filter(|cell| !cell.breakable)
+ .map(|cell| self.grid.effective_rowspan_of_cell(cell))
+ .max()
+ .unwrap_or(0)
+ }
+
+ /// Used by `measure_auto_row` to gather data needed to measure the cell.
+ pub(super) fn prepare_auto_row_cell_measurement(
+ &self,
+ parent: Axes<usize>,
+ cell: &Cell,
+ breakable: bool,
+ row_group_data: Option<&UnbreakableRowGroup>,
+ ) -> CellMeasurementData<'_> {
+ let rowspan = self.grid.effective_rowspan_of_cell(cell);
+
+ // This variable is used to construct a custom backlog if the cell
+ // is a rowspan. When measuring, we join the heights from previous
+ // regions to the current backlog to form the rowspan's expected
+ // backlog.
+ let mut rowspan_backlog: Vec<Abs> = vec![];
+
+ // Each declaration, from top to bottom:
+ // 1. The height available to the cell in the first region.
+ // Usually, this will just be the size remaining in the current
+ // region.
+ // 2. The backlog of upcoming region heights to specify as
+ // available to the cell.
+ // 3. The full height of the first region of the cell.
+ // 4. The total height of the cell covered by previously spanned
+ // rows in this region. This is used by rowspans to be able to tell
+ // how much the auto row needs to expand.
+ // 5. The amount of frames laid out by this cell in previous
+ // regions. When the cell isn't a rowspan, this is always zero.
+ // These frames are skipped after measuring.
+ let (height, backlog, full, height_in_this_region, frames_in_previous_regions);
+ if rowspan == 1 {
+ // Not a rowspan, so the cell only occupies this row. Therefore:
+ // 1. When we measure the cell below, use the available height
+ // remaining in the region as the height it has available.
+ // However, if the auto row is unbreakable, measure with infinite
+ // height instead to see how much content expands.
+ // 2. Also use the region's backlog when measuring.
+ // 3. Use the same full region height.
+ // 4. No height occupied by this cell in this region so far.
+ // 5. Yes, this cell started in this region.
+ height = if breakable { self.regions.size.y } else { Abs::inf() };
+ backlog = Some(self.regions.backlog);
+ full = if breakable { self.regions.full } else { Abs::inf() };
+ height_in_this_region = Abs::zero();
+ frames_in_previous_regions = 0;
+ } else {
+ // Height of the rowspan covered by spanned rows in the current
+ // region.
+ let laid_out_height: Abs = self
+ .lrows
+ .iter()
+ .filter_map(|row| match row {
+ Row::Frame(frame, y, _)
+ if (parent.y..parent.y + rowspan).contains(y) =>
+ {
+ Some(frame.height())
+ }
+ // Either we have a row outside of the rowspan, or a
+ // fractional row, whose size we can't really guess.
+ _ => None,
+ })
+ .sum();
+
+ // If we're currently simulating an unbreakable row group, also
+ // consider the height of previously spanned rows which are in
+ // the row group but not yet laid out.
+ let unbreakable_height: Abs = row_group_data
+ .into_iter()
+ .flat_map(|row_group| &row_group.rows)
+ .filter(|(y, _)| (parent.y..parent.y + rowspan).contains(y))
+ .map(|(_, height)| height)
+ .sum();
+
+ height_in_this_region = laid_out_height + unbreakable_height;
+
+ // Ensure we will measure the rowspan with the correct heights.
+ // For that, we will gather the total height spanned by this
+ // rowspan in previous regions.
+ if let Some((rowspan_full, [rowspan_height, rowspan_other_heights @ ..])) =
+ self.rowspans
+ .iter()
+ .find(|data| data.x == parent.x && data.y == parent.y)
+ .map(|data| (data.region_full, &*data.heights))
+ {
+ // The rowspan started in a previous region (as it already
+ // has at least one region height).
+ // Therefore, its initial height will be the height in its
+ // first spanned region, and the backlog will be the
+ // remaining heights, plus the current region's size, plus
+ // the current backlog.
+ frames_in_previous_regions = rowspan_other_heights.len() + 1;
+
+ let heights_up_to_current_region = rowspan_other_heights
+ .iter()
+ .copied()
+ .chain(std::iter::once(if breakable {
+ self.initial.y
+ } else {
+ // When measuring unbreakable auto rows, infinite
+ // height is available for content to expand.
+ Abs::inf()
+ }));
+
+ rowspan_backlog = if breakable {
+ // This auto row is breakable. Therefore, join the
+ // rowspan's already laid out heights with the current
+ // region's height and current backlog to ensure a good
+ // level of accuracy in the measurements.
+ heights_up_to_current_region
+ .chain(self.regions.backlog.iter().copied())
+ .collect::<Vec<_>>()
+ } else {
+ // No extra backlog if this is an unbreakable auto row.
+ // Ensure, when measuring, that the rowspan can be laid
+ // out through all spanned rows which were already laid
+ // out so far, but don't go further than this region.
+ heights_up_to_current_region.collect::<Vec<_>>()
+ };
+
+ height = *rowspan_height;
+ backlog = None;
+ full = rowspan_full;
+ } else {
+ // The rowspan started in the current region, as its vector
+ // of heights in regions is currently empty.
+ // Therefore, the initial height it has available will be
+ // the current available size, plus the size spanned in
+ // previous rows in this region (and/or unbreakable row
+ // group, if it's being simulated).
+ // The backlog and full will be that of the current region.
+ // However, use infinite height instead if we're measuring an
+ // unbreakable auto row.
+ height = if breakable {
+ height_in_this_region + self.regions.size.y
+ } else {
+ Abs::inf()
+ };
+ backlog = Some(self.regions.backlog);
+ full = if breakable { self.regions.full } else { Abs::inf() };
+ frames_in_previous_regions = 0;
+ }
+ }
+
+ let width = self.cell_spanned_width(cell, parent.x);
+ CellMeasurementData {
+ width,
+ height,
+ backlog,
+ custom_backlog: rowspan_backlog,
+ full,
+ height_in_this_region,
+ frames_in_previous_regions,
+ }
+ }
+
+ /// Used in `measure_auto_row` to prepare a rowspan's `sizes` vector.
+ /// Returns `true` if we'll need to run a simulation to more accurately
+ /// expand the auto row based on the rowspan's demanded size, or `false`
+ /// otherwise.
+ #[allow(clippy::too_many_arguments)]
+ pub(super) fn prepare_rowspan_sizes(
+ &self,
+ auto_row_y: usize,
+ sizes: &mut Vec<Abs>,
+ cell: &Cell,
+ parent_y: usize,
+ rowspan: usize,
+ unbreakable_rows_left: usize,
+ measurement_data: &CellMeasurementData<'_>,
+ ) -> bool {
+ if sizes.len() <= 1
+ && sizes.first().map_or(true, |&first_frame_size| {
+ first_frame_size <= measurement_data.height_in_this_region
+ })
+ {
+ // Ignore a rowspan fully covered by rows in previous
+ // regions and/or in the current region.
+ sizes.clear();
+ return false;
+ }
+ if let Some(first_frame_size) = sizes.first_mut() {
+ // Subtract already covered height from the size requested
+ // by this rowspan to the auto row in the first region.
+ *first_frame_size = (*first_frame_size
+ - measurement_data.height_in_this_region)
+ .max(Abs::zero());
+ }
+
+ let last_spanned_row = parent_y + rowspan - 1;
+
+ // When the rowspan is unbreakable, or all of its upcoming
+ // spanned rows are in the same unbreakable row group, its
+ // spanned gutter will certainly be in the same region as all
+ // of its other spanned rows, thus gutters won't be removed,
+ // and we can safely reduce how much the auto row expands by
+ // without using simulation.
+ let is_effectively_unbreakable_rowspan =
+ !cell.breakable || auto_row_y + unbreakable_rows_left > last_spanned_row;
+
+ // If the rowspan doesn't end at this row and the grid has
+ // gutter, we will need to run a simulation to find out how
+ // much to expand this row by later. This is because gutters
+ // spanned by this rowspan might be removed if they appear
+ // around a pagebreak, so the auto row might have to expand a
+ // bit more to compensate for the missing gutter height.
+ // However, unbreakable rowspans aren't affected by that
+ // problem.
+ if auto_row_y != last_spanned_row
+ && !sizes.is_empty()
+ && self.grid.has_gutter
+ && !is_effectively_unbreakable_rowspan
+ {
+ return true;
+ }
+
+ // We can only predict the resolved size of upcoming fixed-size
+ // rows, but not fractional rows. In the future, we might be
+ // able to simulate and circumvent the problem with fractional
+ // rows. Relative rows are currently always measured relative
+ // to the first region as well.
+ // We can ignore auto rows since this is the last spanned auto
+ // row.
+ let will_be_covered_height: Abs = self
+ .grid
+ .rows
+ .iter()
+ .skip(auto_row_y + 1)
+ .take(last_spanned_row - auto_row_y)
+ .map(|row| match row {
+ Sizing::Rel(v) => {
+ v.resolve(self.styles).relative_to(self.regions.base().y)
+ }
+ _ => Abs::zero(),
+ })
+ .sum();
+
+ // Remove or reduce the sizes of the rowspan at the current or future
+ // regions where it will already be covered by further rows spanned by
+ // it.
+ subtract_end_sizes(sizes, will_be_covered_height);
+
+ // No need to run a simulation for this rowspan.
+ false
+ }
+
+ /// Performs a simulation to predict by how much height the last spanned
+ /// auto row will have to expand, given the current sizes of the auto row
+ /// in each region and the pending rowspans' data (parent Y, rowspan amount
+ /// and vector of requested sizes).
+ pub(super) fn simulate_and_measure_rowspans_in_auto_row(
+ &self,
+ y: usize,
+ resolved: &mut Vec<Abs>,
+ pending_rowspans: &[(usize, usize, Vec<Abs>)],
+ unbreakable_rows_left: usize,
+ row_group_data: Option<&UnbreakableRowGroup>,
+ engine: &mut Engine,
+ ) -> SourceResult<()> {
+ // To begin our simulation, we have to unify the sizes demanded by
+ // each rowspan into one simple vector of sizes, as if they were
+ // all a single rowspan. These sizes will be appended to
+ // 'resolved' once we finish our simulation.
+ let mut simulated_sizes: Vec<Abs> = vec![];
+ let last_resolved_size = resolved.last().copied();
+ let mut max_spanned_row = y;
+ for (parent_y, rowspan, sizes) in pending_rowspans {
+ let mut sizes = sizes.iter();
+ for (target, size) in resolved.iter_mut().zip(&mut sizes) {
+ // First, we update the already resolved sizes as required
+ // by this rowspan. No need to simulate this since the auto row
+ // will already expand throughout already resolved regions.
+ // Our simulation, therefore, won't otherwise change already
+ // resolved sizes, other than, perhaps, the last one (at the
+ // last currently resolved region, at which we can expand).
+ target.set_max(*size);
+ }
+ for (simulated_target, rowspan_size) in
+ simulated_sizes.iter_mut().zip(&mut sizes)
+ {
+ // The remaining sizes are exclusive to rowspans, since
+ // other cells in this row didn't require as many regions.
+ // We will perform a simulation to see how much of these sizes
+ // does the auto row actually need to expand by, and how much
+ // is already covered by upcoming rows spanned by the rowspans.
+ simulated_target.set_max(*rowspan_size);
+ }
+ simulated_sizes.extend(sizes);
+ max_spanned_row = max_spanned_row.max(parent_y + rowspan - 1);
+ }
+ if simulated_sizes.is_empty() && resolved.last() == last_resolved_size.as_ref() {
+ // The rowspans already fit in the already resolved sizes.
+ // No need for simulation.
+ return Ok(());
+ }
+
+ // We will be updating the last resolved size (expanding the auto
+ // row) as needed. Therefore, consider it as part of the simulation.
+ // At the end, we push it back.
+ if let Some(modified_last_resolved_size) = resolved.pop() {
+ simulated_sizes.insert(0, modified_last_resolved_size);
+ }
+
+ // Prepare regions for simulation.
+ // If we're currently inside an unbreakable row group simulation,
+ // subtract the current row group height from the available space
+ // when simulating rowspans in said group.
+ let mut simulated_regions = self.regions;
+ simulated_regions.size.y -=
+ row_group_data.map_or(Abs::zero(), |row_group| row_group.height);
+
+ for _ in 0..resolved.len() {
+ // Ensure we start at the region where we will expand the auto
+ // row.
+ // Note that we won't accidentally call '.next()' once more than
+ // desired (we won't skip the last resolved frame, where we will
+ // expand) because we popped the last resolved size from the
+ // resolved vector, above.
+ simulated_regions.next();
+ }
+ if let Some(original_last_resolved_size) = last_resolved_size {
+ // We're now at the (current) last region of this auto row.
+ // Consider resolved height as already taken space.
+ simulated_regions.size.y -= original_last_resolved_size;
+ }
+
+ // Now we run the simulation to check how much the auto row needs to
+ // grow to ensure that rowspans have the height they need.
+ let simulations_stabilized = self.run_rowspan_simulation(
+ y,
+ max_spanned_row,
+ simulated_regions,
+ &mut simulated_sizes,
+ engine,
+ last_resolved_size,
+ unbreakable_rows_left,
+ )?;
+
+ if !simulations_stabilized {
+ // If the simulation didn't stabilize above, we will just pretend
+ // all gutters were removed, as a best effort. That means the auto
+ // row will expand more than it normally should, but there isn't
+ // much we can do.
+ let will_be_covered_height = self
+ .grid
+ .rows
+ .iter()
+ .enumerate()
+ .skip(y + 1)
+ .take(max_spanned_row - y)
+ .filter(|(y, _)| !self.grid.is_gutter_track(*y))
+ .map(|(_, row)| match row {
+ Sizing::Rel(v) => {
+ v.resolve(self.styles).relative_to(self.regions.base().y)
+ }
+ _ => Abs::zero(),
+ })
+ .sum();
+
+ subtract_end_sizes(&mut simulated_sizes, will_be_covered_height);
+ }
+
+ resolved.extend(simulated_sizes);
+
+ Ok(())
+ }
+
+ /// Performs a simulation of laying out multiple rowspans (consolidated
+ /// into a single vector of simulated sizes) ending in a certain auto row
+ /// in order to find out how much the auto row will need to expand to cover
+ /// the rowspans' requested sizes, considering how much size has been
+ /// covered by other rows and by gutter between rows.
+ ///
+ /// For example, for a rowspan cell containing a block of 8pt of height
+ /// spanning rows (1pt, auto, 0.5pt, 0.5pt), with a gutter of 1pt between
+ /// each row, we have that the rows it spans provide 1pt + 0.5pt + 0.5pt
+ /// = 2pt of height, plus 1pt + 1pt + 1pt = 3pt of gutter, with a total of
+ /// 2pt + 3pt = 5pt of height already covered by fixed-size rows and
+ /// gutters. This means that the auto row must (under normal conditions)
+ /// expand by 3pt (8pt - 5pt) so that the rowspan has enough height across
+ /// rows to fully draw its contents.
+ ///
+ /// However, it's possible that the last row is sent to the next page to
+ /// respect a pagebreak, and then the 1pt gutter before it disappears. This
+ /// would lead to our rowspan having a height of 7pt available if we fail
+ /// to predict this situation when measuring the auto row.
+ ///
+ /// The algorithm below will, thus, attempt to simulate the layout of each
+ /// spanned row, considering the space available in the current page and in
+ /// upcoming pages (through the region backlog), in order to predict which
+ /// rows will be sent to a new page and thus have their preceding gutter
+ /// spacing removed (meaning the auto row has to grow a bit more). After
+ /// simulating, we subtract the total height spanned by upcoming rows and
+ /// gutter from the total rowspan height - this will be how much our auto
+ /// row has to expand. We then simulate again to check if, if the auto row
+ /// expanded by that amount, that would prompt the auto row to need to
+ /// expand even more, because expanding the auto row might cause some other
+ /// larger gutter spacing to disappear (leading to the rowspan having less
+ /// space available instead of more); if so, we update the amount to expand
+ /// and run the simulation again. Otherwise (if it should expand by the
+ /// same amount, meaning we predicted correctly, or by less, meaning the
+ /// auto row will be a bit larger than it should be, but that's a
+ /// compromise we're willing to accept), we conclude the simulation
+ /// (consider it stabilized) and return the result.
+ ///
+ /// Tries up to 5 times. If two consecutive simulations stabilize, then
+ /// we subtract the predicted expansion height ('amount_to_grow') from the
+ /// total height requested by rowspans (the 'requested_rowspan_height') to
+ /// obtain how much height is covered by upcoming rows, according to our
+ /// simulation, and the result of that operation is used to reduce or
+ /// remove heights from the end of the vector of simulated sizes, such that
+ /// the remaining heights are exactly how much the auto row should expand
+ /// by. Then, we return `true`.
+ ///
+ /// If the simulations don't stabilize (they return 5 different and
+ /// successively larger values), aborts and returns `false`.
+ #[allow(clippy::too_many_arguments)]
+ fn run_rowspan_simulation(
+ &self,
+ y: usize,
+ max_spanned_row: usize,
+ mut simulated_regions: Regions<'_>,
+ simulated_sizes: &mut Vec<Abs>,
+ engine: &mut Engine,
+ last_resolved_size: Option<Abs>,
+ unbreakable_rows_left: usize,
+ ) -> SourceResult<bool> {
+ // The max amount this row can expand will be the total size requested
+ // by rowspans which was not yet resolved. It is worth noting that,
+ // earlier, we pushed the last resolved size to 'simulated_sizes' as
+ // row expansion starts with it, so it's possible a rowspan requested
+ // to extend that size (we will see, through the simulation, if that's
+ // needed); however, we must subtract that resolved size from the total
+ // sum of sizes, as it was already resolved and thus the auto row will
+ // already grow by at least that much in the last resolved region (we
+ // would grow by the same size twice otherwise).
+ let requested_rowspan_height =
+ simulated_sizes.iter().sum::<Abs>() - last_resolved_size.unwrap_or_default();
+
+ // The amount the row will effectively grow by, according to the latest
+ // simulation.
+ let mut amount_to_grow = Abs::zero();
+
+ // Try to simulate up to 5 times. If it doesn't stabilize at a value
+ // which, when used and combined with upcoming spanned rows, covers all
+ // of the requested rowspan height, we give up.
+ for _attempt in 0..5 {
+ let mut regions = simulated_regions;
+ let mut total_spanned_height = Abs::zero();
+ let mut unbreakable_rows_left = unbreakable_rows_left;
+
+ // Height of the latest spanned gutter row.
+ // Zero if it was removed.
+ let mut latest_spanned_gutter_height = Abs::zero();
+ let spanned_rows = &self.grid.rows[y + 1..=max_spanned_row];
+ for (offset, row) in spanned_rows.iter().enumerate() {
+ if (total_spanned_height + amount_to_grow).fits(requested_rowspan_height)
+ {
+ // Stop the simulation, as the combination of upcoming
+ // spanned rows (so far) and the current amount the auto
+ // row expands by has already fully covered the height the
+ // rowspans need.
+ break;
+ }
+ let spanned_y = y + 1 + offset;
+ let is_gutter = self.grid.is_gutter_track(spanned_y);
+
+ if unbreakable_rows_left == 0 {
+ // Simulate unbreakable row groups, and skip regions until
+ // they fit. There is no risk of infinite recursion, as
+ // no auto rows participate in the simulation, so the
+ // unbreakable row group simulator won't recursively call
+ // 'measure_auto_row' or (consequently) this function.
+ let row_group =
+ self.simulate_unbreakable_row_group(spanned_y, &regions, engine)?;
+ while !regions.size.y.fits(row_group.height) && !regions.in_last() {
+ total_spanned_height -= latest_spanned_gutter_height;
+ latest_spanned_gutter_height = Abs::zero();
+ regions.next();
+ }
+
+ unbreakable_rows_left = row_group.rows.len();
+ }
+
+ match row {
+ // Fixed-size spanned rows are what we are interested in.
+ // They contribute a fixed amount of height to our rowspan.
+ Sizing::Rel(v) => {
+ let height = v.resolve(self.styles).relative_to(regions.base().y);
+ total_spanned_height += height;
+ if is_gutter {
+ latest_spanned_gutter_height = height;
+ }
+
+ let mut skipped_region = false;
+ while unbreakable_rows_left == 0
+ && !regions.size.y.fits(height)
+ && !regions.in_last()
+ {
+ // A row was pushed to the next region. Therefore,
+ // the immediately preceding gutter row is removed.
+ total_spanned_height -= latest_spanned_gutter_height;
+ latest_spanned_gutter_height = Abs::zero();
+ skipped_region = true;
+ regions.next();
+ }
+
+ if !skipped_region || !is_gutter {
+ // No gutter at the top of a new region, so don't
+ // account for it if we just skipped a region.
+ regions.size.y -= height;
+ }
+ }
+ Sizing::Auto => {
+ // We only simulate for rowspans which end at the
+ // current auto row. Therefore, there won't be any
+ // further auto rows.
+ unreachable!();
+ }
+ // For now, we ignore fractional rows on simulation.
+ Sizing::Fr(_) if is_gutter => {
+ latest_spanned_gutter_height = Abs::zero();
+ }
+ Sizing::Fr(_) => {}
+ }
+
+ unbreakable_rows_left = unbreakable_rows_left.saturating_sub(1);
+ }
+
+ // If the total height spanned by upcoming spanned rows plus the
+ // current amount we predict the auto row will have to grow (from
+ // the previous iteration) are larger than the size requested by
+ // rowspans, this means the auto row will grow enough in order to
+ // cover the requested rowspan height, so we stop the simulation.
+ //
+ // If that's not yet the case, we will simulate again and make the
+ // auto row grow even more, and do so until either the auto row has
+ // grown enough, or we tried to do so over 5 times.
+ //
+ // A flaw of this approach is that we consider rowspans' content to
+ // be contiguous. That is, we treat rowspans' requested heights as
+ // a simple number, instead of properly using the vector of
+ // requested heights in each region. This can lead to some
+ // weirdness when using multi-page rowspans with content that
+ // reacts to the amount of space available, including paragraphs.
+ // However, this is probably the best we can do for now.
+ if (total_spanned_height + amount_to_grow).fits(requested_rowspan_height) {
+ // Reduce sizes by the amount to be covered by upcoming spanned
+ // rows, which is equivalent to the amount that we don't grow.
+ // We reduce from the end as that's where the spanned rows will
+ // cover. The remaining sizes will all be covered by the auto
+ // row instead (which will grow by those sizes).
+ subtract_end_sizes(
+ simulated_sizes,
+ requested_rowspan_height - amount_to_grow,
+ );
+
+ if let Some(last_resolved_size) = last_resolved_size {
+ // Ensure the first simulated size is at least as large as
+ // the last resolved size (its initial value). As it was
+ // already resolved before, we must not reduce below the
+ // resolved size to avoid problems with non-rowspan cells.
+ if let Some(first_simulated_size) = simulated_sizes.first_mut() {
+ first_simulated_size.set_max(last_resolved_size);
+ } else {
+ simulated_sizes.push(last_resolved_size);
+ }
+ }
+
+ return Ok(true);
+ }
+
+ // For the next simulation, we will test if the auto row can grow
+ // by precisely how much rowspan height is not covered by upcoming
+ // spanned rows, according to the current simulation.
+ // We know that the new amount to grow is larger (and thus the
+ // auto row only expands between each simulation), because we
+ // checked above if
+ // 'total_spanned_height + (now old_)amount_to_grow >= requested_rowspan_height',
+ // which was false, so it holds that
+ // 'total_spanned_height + old_amount_to_grow < requested_rowspan_height'
+ // Thus,
+ // 'old_amount_to_grow < requested_rowspan_height - total_spanned_height'
+ // Therefore, by definition, 'old_amount_to_grow < amount_to_grow'.
+ let old_amount_to_grow = std::mem::replace(
+ &mut amount_to_grow,
+ requested_rowspan_height - total_spanned_height,
+ );
+
+ // We advance the 'regions' variable accordingly, so that, in the
+ // next simulation, we consider already grown space as final.
+ // That is, we effectively simulate how rows would be placed if the
+ // auto row grew by precisely the new value of 'amount_to_grow'.
+ let mut extra_amount_to_grow = amount_to_grow - old_amount_to_grow;
+ while extra_amount_to_grow > Abs::zero()
+ && simulated_regions.size.y < extra_amount_to_grow
+ {
+ extra_amount_to_grow -= simulated_regions.size.y.max(Abs::zero());
+ simulated_regions.next();
+ }
+ simulated_regions.size.y -= extra_amount_to_grow;
+ }
+
+ // Simulation didn't succeed in 5 attempts.
+ Ok(false)
+ }
+}
+
+/// Subtracts some size from the end of a vector of sizes.
+/// For example, subtracting 5pt from \[2pt, 1pt, 3pt\] will result in \[1pt\].
+fn subtract_end_sizes(sizes: &mut Vec<Abs>, mut subtract: Abs) {
+ while subtract > Abs::zero() && sizes.last().is_some_and(|&size| size <= subtract) {
+ subtract -= sizes.pop().unwrap();
+ }
+ if subtract > Abs::zero() {
+ if let Some(last_size) = sizes.last_mut() {
+ *last_size -= subtract;
+ }
+ }
+}
diff --git a/crates/typst/src/model/table.rs b/crates/typst/src/model/table.rs
index a51fd1e7..79521f2d 100644
--- a/crates/typst/src/model/table.rs
+++ b/crates/typst/src/model/table.rs
@@ -535,6 +535,10 @@ pub struct TableCell {
#[default(NonZeroUsize::ONE)]
pub colspan: NonZeroUsize,
+ /// The amount of rows spanned by this cell.
+ #[default(NonZeroUsize::ONE)]
+ rowspan: NonZeroUsize,
+
/// The cell's alignment override.
pub align: Smart<Alignment>,
@@ -545,6 +549,12 @@ pub struct TableCell {
#[resolve]
#[fold]
pub stroke: Sides<Option<Option<Arc<Stroke>>>>,
+
+ /// Whether rows spanned by this cell can be placed in different pages.
+ /// When equal to `{auto}`, a cell spanning only fixed-size rows is
+ /// unbreakable, while a cell spanning at least one `{auto}`-sized row is
+ /// breakable.
+ pub breakable: Smart<bool>,
}
cast! {
@@ -567,10 +577,13 @@ impl ResolvableCell for Packed<TableCell> {
align: Smart<Alignment>,
inset: Sides<Option<Rel<Length>>>,
stroke: Sides<Option<Option<Arc<Stroke<Abs>>>>>,
+ breakable: bool,
styles: StyleChain,
) -> Cell {
let cell = &mut *self;
let colspan = cell.colspan(styles);
+ let rowspan = cell.rowspan(styles);
+ let breakable = cell.breakable(styles).unwrap_or(breakable);
let fill = cell.fill(styles).unwrap_or_else(|| fill.clone());
let cell_stroke = cell.stroke(styles);
@@ -615,12 +628,15 @@ impl ResolvableCell for Packed<TableCell> {
}))
}),
);
+ cell.push_breakable(Smart::Custom(breakable));
Cell {
body: self.pack(),
fill,
colspan,
+ rowspan,
stroke,
stroke_overridden,
+ breakable,
}
}
@@ -632,10 +648,14 @@ impl ResolvableCell for Packed<TableCell> {
(**self).y(styles)
}
- fn colspan(&self, styles: StyleChain) -> std::num::NonZeroUsize {
+ fn colspan(&self, styles: StyleChain) -> NonZeroUsize {
(**self).colspan(styles)
}
+ fn rowspan(&self, styles: StyleChain) -> NonZeroUsize {
+ (**self).rowspan(styles)
+ }
+
fn span(&self) -> Span {
Packed::span(self)
}
diff --git a/tests/ref/bugs/grid-4.png b/tests/ref/bugs/grid-4.png
new file mode 100644
index 00000000..475f561e
--- /dev/null
+++ b/tests/ref/bugs/grid-4.png
Binary files differ
diff --git a/tests/ref/layout/grid-rowspan-basic.png b/tests/ref/layout/grid-rowspan-basic.png
new file mode 100644
index 00000000..966c8fd9
--- /dev/null
+++ b/tests/ref/layout/grid-rowspan-basic.png
Binary files differ
diff --git a/tests/ref/layout/grid-rowspan-split-1.png b/tests/ref/layout/grid-rowspan-split-1.png
new file mode 100644
index 00000000..12cd5fc6
--- /dev/null
+++ b/tests/ref/layout/grid-rowspan-split-1.png
Binary files differ
diff --git a/tests/ref/layout/grid-rowspan-split-2.png b/tests/ref/layout/grid-rowspan-split-2.png
new file mode 100644
index 00000000..e55c5e23
--- /dev/null
+++ b/tests/ref/layout/grid-rowspan-split-2.png
Binary files differ
diff --git a/tests/ref/layout/grid-rowspan-split-3.png b/tests/ref/layout/grid-rowspan-split-3.png
new file mode 100644
index 00000000..3d809123
--- /dev/null
+++ b/tests/ref/layout/grid-rowspan-split-3.png
Binary files differ
diff --git a/tests/ref/layout/grid-rtl.png b/tests/ref/layout/grid-rtl.png
index f81e992e..3cf0b9aa 100644
--- a/tests/ref/layout/grid-rtl.png
+++ b/tests/ref/layout/grid-rtl.png
Binary files differ
diff --git a/tests/ref/layout/grid-stroke.png b/tests/ref/layout/grid-stroke.png
index 0f0b562a..409d10f1 100644
--- a/tests/ref/layout/grid-stroke.png
+++ b/tests/ref/layout/grid-stroke.png
Binary files differ
diff --git a/tests/typ/bugs/grid-4.typ b/tests/typ/bugs/grid-4.typ
new file mode 100644
index 00000000..691bf877
--- /dev/null
+++ b/tests/typ/bugs/grid-4.typ
@@ -0,0 +1,17 @@
+// Ensure gutter rows at the top or bottom of a region are skipped.
+
+---
+#set page(height: 10em)
+
+#table(
+ row-gutter: 1.5em,
+ inset: 0pt,
+ rows: (1fr, auto),
+ [a],
+ [],
+ [],
+ [f],
+ [e\ e],
+ [],
+ [a]
+)
diff --git a/tests/typ/layout/grid-colspan.typ b/tests/typ/layout/grid-colspan.typ
index 3fd1a0fd..1bdadcf1 100644
--- a/tests/typ/layout/grid-colspan.typ
+++ b/tests/typ/layout/grid-colspan.typ
@@ -81,7 +81,7 @@
---
// Error: 4:8-4:32 cell would span a previously placed cell at column 2, row 0
-// Hint: 4:8-4:32 try specifying your cells in a different order or reducing the cell's colspan
+// Hint: 4:8-4:32 try specifying your cells in a different order or reducing the cell's rowspan or colspan
#grid(
columns: 3,
grid.cell(x: 2, y: 0)[x],
diff --git a/tests/typ/layout/grid-positioning.typ b/tests/typ/layout/grid-positioning.typ
index ca71cb37..5461fb1c 100644
--- a/tests/typ/layout/grid-positioning.typ
+++ b/tests/typ/layout/grid-positioning.typ
@@ -221,3 +221,11 @@
fill: (x, y) => if calc.odd(x + y) { red.lighten(50%) } else { green },
table.cell(x: 2, y: 6148914691236517206)[a],
)
+
+---
+// Error: 3:3-3:45 cell would span an exceedingly large position
+// Hint: 3:3-3:45 try reducing the cell's rowspan or colspan
+#grid(
+ columns: 500,
+ grid.cell(rowspan: 6148914691236517206)[a]
+)
diff --git a/tests/typ/layout/grid-rowspan-basic.typ b/tests/typ/layout/grid-rowspan-basic.typ
new file mode 100644
index 00000000..49164fa6
--- /dev/null
+++ b/tests/typ/layout/grid-rowspan-basic.typ
@@ -0,0 +1,211 @@
+#grid(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ grid.cell(rowspan: 2, fill: orange)[*Left*],
+ [Right A], [Right A], [Right A],
+ [Right B], grid.cell(colspan: 2, rowspan: 2, fill: orange.darken(10%))[B Wide],
+ [Left A], [Left A],
+ [Left B], [Left B], grid.cell(colspan: 2, rowspan: 3, fill: orange)[Wide and Long]
+)
+
+#table(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ table.cell(rowspan: 2, fill: orange)[*Left*],
+ [Right A], [Right A], [Right A],
+ [Right B], table.cell(colspan: 2, rowspan: 2, fill: orange.darken(10%))[B Wide],
+ [Left A], [Left A],
+ [Left B], [Left B], table.cell(colspan: 2, rowspan: 3, fill: orange)[Wide and Long]
+)
+
+---
+#grid(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ gutter: 3pt,
+ grid.cell(rowspan: 2, fill: orange)[*Left*],
+ [Right A], [Right A], [Right A],
+ [Right B], grid.cell(colspan: 2, rowspan: 2, fill: orange.darken(10%))[B Wide],
+ [Left A], [Left A],
+ [Left B], [Left B], grid.cell(colspan: 2, rowspan: 3, fill: orange)[Wide and Long]
+)
+
+#table(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ gutter: 3pt,
+ table.cell(rowspan: 2, fill: orange)[*Left*],
+ [Right A], [Right A], [Right A],
+ [Right B], table.cell(colspan: 2, rowspan: 2, fill: orange.darken(10%))[B Wide],
+ [Left A], [Left A],
+ [Left B], [Left B], table.cell(colspan: 2, rowspan: 3, fill: orange)[Wide and Long]
+)
+
+---
+// Fixed-size rows
+#set page(height: 10em)
+#grid(
+ columns: 2,
+ rows: 1.5em,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ grid.cell(rowspan: 3)[R1], [b],
+ [c],
+ [d],
+ [e], [f],
+ grid.cell(rowspan: 5)[R2], [h],
+ [i],
+ [j],
+ [k],
+ [l],
+ [m], [n]
+)
+
+---
+// Cell coordinate tests
+#set page(height: 10em)
+#show table.cell: it => [(#it.x, #it.y)]
+#table(
+ columns: 3,
+ fill: red,
+ [a], [b], table.cell(rowspan: 2)[c],
+ table.cell(colspan: 2)[d],
+ table.cell(colspan: 3, rowspan: 10)[a],
+ table.cell(colspan: 2)[b],
+)
+#table(
+ columns: 3,
+ gutter: 3pt,
+ fill: red,
+ [a], [b], table.cell(rowspan: 2)[c],
+ table.cell(colspan: 2)[d],
+ table.cell(colspan: 3, rowspan: 9)[a],
+ table.cell(colspan: 2)[b],
+)
+
+---
+// Auto row expansion
+#set page(height: 10em)
+#grid(
+ columns: (1em, 1em),
+ rows: (0.5em, 0.5em, auto),
+ fill: orange,
+ gutter: 3pt,
+ grid.cell(rowspan: 4, [x x x x] + place(bottom)[*Bot*]),
+ [a],
+ [b],
+ [c],
+ [d]
+)
+
+---
+// Excessive rowspan (no gutter)
+#set page(height: 10em)
+#table(
+ columns: 4,
+ fill: red,
+ [a], [b], table.cell(rowspan: 2)[c], [d],
+ table.cell(colspan: 2, stroke: (bottom: aqua + 2pt))[e], table.cell(stroke: (bottom: aqua))[f],
+ table.cell(colspan: 2, rowspan: 10)[R1], table.cell(colspan: 2, rowspan: 10)[R2],
+ [b],
+)
+
+---
+// Excessive rowspan (with gutter)
+#set page(height: 10em)
+#table(
+ columns: 4,
+ gutter: 3pt,
+ fill: red,
+ [a], [b], table.cell(rowspan: 2)[c], [d],
+ table.cell(colspan: 2, stroke: (bottom: aqua + 2pt))[e], table.cell(stroke: (bottom: aqua))[f],
+ table.cell(colspan: 2, rowspan: 10)[R1], table.cell(colspan: 2, rowspan: 10)[R2],
+ [b],
+)
+
+---
+// Fractional rows
+// They cause the auto row to expand more than needed.
+#set page(height: 10em)
+#grid(
+ fill: red,
+ gutter: 3pt,
+ columns: 3,
+ rows: (1em, auto, 1fr),
+ [a], [b], grid.cell(rowspan: 3, block(height: 4em, width: 1em, fill: orange)),
+ [c], [d],
+ [e], [f]
+)
+
+---
+// Fractional rows
+#set page(height: 10em)
+#grid(
+ fill: red,
+ gutter: 3pt,
+ columns: 3,
+ rows: (1fr, auto, 1em),
+ [a], [b], grid.cell(rowspan: 3, block(height: 4em, width: 1em, fill: orange)),
+ [c], [d],
+ [e], [f]
+)
+
+---
+// Cell order
+#let count = counter("count")
+#show grid.cell: it => {
+ count.step()
+ count.display()
+}
+
+#grid(
+ columns: (2em,) * 3,
+ stroke: aqua,
+ rows: 1.2em,
+ fill: (x, y) => if calc.odd(x + y) { red } else { orange },
+ [a], grid.cell(rowspan: 2)[b], grid.cell(rowspan: 2)[c],
+ [d],
+ grid.cell(rowspan: 2)[f], [g], [h],
+ [i], [j],
+ [k], [l], [m],
+ grid.cell(rowspan: 2)[n], [o], [p],
+ [q], [r],
+ [s], [t], [u]
+)
+
+---
+#table(
+ columns: 3,
+ rows: (auto, auto, auto, 2em),
+ gutter: 3pt,
+ table.cell(rowspan: 4)[a \ b\ c\ d\ e], [c], [d],
+ [e], table.cell(breakable: false, rowspan: 2)[f],
+ [g]
+)
+
+---
+// Test cell breakability
+#show grid.cell: it => {
+ assert.eq(it.breakable, (it.x, it.y) != (0, 6) and (it.y in (2, 5, 6) or (it.x, it.y) in ((0, 1), (2, 3), (1, 7))))
+ it.breakable
+}
+#grid(
+ columns: 3,
+ rows: (6pt, 1fr, auto, 1%, 1em, auto, auto, 0.2in),
+ row-gutter: (0pt, 0pt, 0pt, auto),
+ [a], [b], [c],
+ grid.cell(rowspan: 3)[d], [e], [f],
+ [g], [h],
+ [i], grid.cell(rowspan: 2)[j],
+ [k],
+ grid.cell(y: 5)[l],
+ grid.cell(y: 6, breakable: false)[m], grid.cell(y: 6, breakable: true)[n],
+ grid.cell(y: 7, breakable: false)[o], grid.cell(y: 7, breakable: true)[p], grid.cell(y: 7, breakable: auto)[q]
+)
diff --git a/tests/typ/layout/grid-rowspan-split-1.typ b/tests/typ/layout/grid-rowspan-split-1.typ
new file mode 100644
index 00000000..e247fa80
--- /dev/null
+++ b/tests/typ/layout/grid-rowspan-split-1.typ
@@ -0,0 +1,89 @@
+// Rowspan split tests
+
+---
+#set page(height: 10em)
+#table(
+ columns: 2,
+ rows: (auto, auto, 3em),
+ fill: red,
+ [a], table.cell(rowspan: 3, block(width: 50%, height: 10em, fill: orange) + place(bottom)[*ZD*]),
+ [e],
+ [f]
+)
+
+---
+#set page(height: 10em)
+#table(
+ columns: 2,
+ rows: (auto, auto, 3em),
+ row-gutter: 1em,
+ fill: red,
+ [a], table.cell(rowspan: 3, block(width: 50%, height: 10em, fill: orange) + place(bottom)[*ZD*]),
+ [e],
+ [f]
+)
+
+---
+#set page(height: 5em)
+#table(
+ columns: 2,
+ fill: red,
+ inset: 0pt,
+ table.cell(fill: orange, rowspan: 10, place(bottom)[*Z*] + [x\ ] * 10 + place(bottom)[*ZZ*]),
+ ..([y],) * 10,
+ [a], [b],
+)
+
+---
+#set page(height: 5em)
+#table(
+ columns: 2,
+ fill: red,
+ inset: 0pt,
+ gutter: 2pt,
+ table.cell(fill: orange, rowspan: 10, place(bottom)[*Z*] + [x\ ] * 10 + place(bottom)[*ZZ*]),
+ ..([y],) * 10,
+ [a], [b],
+)
+
+---
+#set page(height: 5em)
+#table(
+ columns: 2,
+ fill: red,
+ inset: 0pt,
+ table.cell(fill: orange, rowspan: 10, breakable: false, place(bottom)[*Z*] + [x\ ] * 10 + place(bottom)[*ZZ*]),
+ ..([y],) * 10,
+ [a], [b],
+)
+
+---
+#set page(height: 5em)
+#table(
+ columns: 2,
+ fill: red,
+ inset: 0pt,
+ gutter: 2pt,
+ table.cell(fill: orange, rowspan: 10, breakable: false, place(bottom)[*Z*] + [x\ ] * 10 + place(bottom)[*ZZ*]),
+ ..([y],) * 10,
+ [a], [b],
+)
+
+---
+#set page(height: 5em)
+#grid(
+ columns: 2,
+ stroke: red,
+ inset: 5pt,
+ grid.cell(rowspan: 5)[a\ b\ c\ d\ e]
+)
+
+---
+#set page(height: 5em)
+#table(
+ columns: 2,
+ gutter: 3pt,
+ stroke: red,
+ inset: 5pt,
+ table.cell(rowspan: 5)[a\ b\ c\ d\ e]
+)
diff --git a/tests/typ/layout/grid-rowspan-split-2.typ b/tests/typ/layout/grid-rowspan-split-2.typ
new file mode 100644
index 00000000..189feed3
--- /dev/null
+++ b/tests/typ/layout/grid-rowspan-split-2.typ
@@ -0,0 +1,37 @@
+// Rowspan split without ending at the auto row
+
+---
+#set page(height: 6em)
+#table(
+ rows: (4em,) * 7 + (auto,) + (4em,) * 7,
+ columns: 2,
+ column-gutter: 1em,
+ row-gutter: (1em, 2em) * 4,
+ fill: (x, y) => if calc.odd(x + y) { orange.lighten(20%) } else { red },
+ table.cell(rowspan: 15, [a \ ] * 15),
+ [] * 15
+)
+
+---
+#set page(height: 6em)
+#table(
+ rows: (4em,) * 7 + (auto,) + (4em,) * 7,
+ columns: 2,
+ column-gutter: 1em,
+ row-gutter: (1em, 2em) * 4,
+ fill: (x, y) => if calc.odd(x + y) { green } else { green.darken(40%) },
+ table.cell(rowspan: 15, block(fill: blue, width: 2em, height: 4em * 14 + 3em)),
+ [] * 15
+)
+
+---
+#set page(height: 6em)
+#table(
+ rows: (3em,) * 15,
+ columns: 2,
+ column-gutter: 1em,
+ row-gutter: (1em, 2em) * 4,
+ fill: (x, y) => if calc.odd(x + y) { aqua } else { blue },
+ table.cell(breakable: true, rowspan: 15, [a \ ] * 15),
+ [] * 15
+)
diff --git a/tests/typ/layout/grid-rowspan-split-3.typ b/tests/typ/layout/grid-rowspan-split-3.typ
new file mode 100644
index 00000000..4c3ce7d8
--- /dev/null
+++ b/tests/typ/layout/grid-rowspan-split-3.typ
@@ -0,0 +1,108 @@
+// Some splitting corner cases
+
+---
+// Inside the larger rowspan's range, there's an unbreakable rowspan and a
+// breakable rowspan. This should work normally.
+// The auto row will also expand ignoring the last fractional row.
+#set page(height: 10em)
+#table(
+ gutter: 0.5em,
+ columns: 2,
+ rows: (2em,) * 10 + (auto, auto, 2em, 1fr),
+ fill: (_, y) => if calc.even(y) { aqua } else { blue },
+ table.cell(rowspan: 14, block(width: 2em, height: 2em * 10 + 2em + 5em, fill: red)[]),
+ ..([a],) * 5,
+ table.cell(rowspan: 3)[a\ b],
+ table.cell(rowspan: 5, [a\ b\ c\ d\ e\ f\ g\ h]),
+ [z]
+)
+
+---
+// Inset moving to next region bug
+#set page(width: 10cm, height: 2.5cm, margin: 0.5cm)
+#set text(size: 11pt)
+#table(
+ columns: (1fr, 1fr, 1fr),
+ [A],
+ [B],
+ [C],
+ [D],
+ table.cell(rowspan: 2, lorem(4)),
+ [E],
+ [F],
+ [G],
+)
+
+---
+// Second lorem must be sent to the next page, too big
+#set page(width: 10cm, height: 9cm, margin: 1cm)
+#set text(size: 11pt)
+#table(
+ columns: (1fr, 1fr, 1fr),
+ align: center,
+ rows: (4cm, auto),
+ [A], [B], [C],
+ table.cell(rowspan: 4, breakable: false, lorem(10)),
+ [D],
+ table.cell(rowspan: 2, breakable: false, lorem(20)),
+ [E],
+)
+
+---
+// Auto row must expand properly in both cases
+#set text(10pt)
+#show table.cell: it => if it.x == 0 { it } else { layout(size => size.height) }
+#table(
+ columns: 2,
+ rows: (1em, auto, 2em, 3em, 4em),
+ gutter: 3pt,
+ table.cell(rowspan: 5, block(fill: orange, height: 15em)[a]),
+ [b],
+ [c],
+ [d],
+ [e],
+ [f]
+)
+
+#table(
+ columns: 2,
+ rows: (1em, auto, 2em, 3em, 4em),
+ gutter: 3pt,
+ table.cell(rowspan: 5, breakable: false, block(fill: orange, height: 15em)[a]),
+ [b],
+ [c],
+ [d],
+ [e],
+ [f]
+)
+
+---
+// Expanding on unbreakable auto row
+#set page(height: 7em, margin: (bottom: 2em))
+#grid(
+ columns: 2,
+ rows: (1em, 1em, auto, 1em, 1em, 1em),
+ fill: (x, y) => if x == 0 { aqua } else { blue },
+ stroke: black,
+ gutter: 2pt,
+ grid.cell(rowspan: 5, block(height: 10em)[a]),
+ [a],
+ [b],
+ grid.cell(breakable: false, v(3em) + [c]),
+ [d],
+ [e],
+ [f], [g]
+)
+
+---
+#show table.cell.where(x: 0): strong
+#show table.cell.where(y: 0): strong
+#set page(height: 13em)
+#let lets-repeat(thing, n) = ((thing + colbreak(),) * (calc.max(0, n - 1)) + (thing,)).join()
+#table(
+ columns: 4,
+ fill: (x, y) => if x == 0 or y == 0 { gray },
+ [], [Test 1], [Test 2], [Test 3],
+ table.cell(rowspan: 15, align: horizon, lets-repeat((rotate(-90deg, reflow: true)[*All Tests*]), 3)),
+ ..([123], [456], [789]) * 15
+)
diff --git a/tests/typ/layout/grid-rtl.typ b/tests/typ/layout/grid-rtl.typ
index dcac9810..be9fac51 100644
--- a/tests/typ/layout/grid-rtl.typ
+++ b/tests/typ/layout/grid-rtl.typ
@@ -137,3 +137,44 @@
#grid(
[a], grid.vline(position: left)
)
+
+---
+#set text(dir: rtl)
+
+#grid(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ grid.cell(rowspan: 2, fill: orange)[*Left*],
+ [Right A], [Right A], [Right A],
+ [Right B], grid.cell(colspan: 2, rowspan: 2, fill: orange.darken(10%))[B Wide],
+ [Left A], [Left A],
+ [Left B], [Left B], grid.cell(colspan: 2, rowspan: 3, fill: orange)[Wide and Long]
+)
+
+#table(
+ columns: 4,
+ fill: (x, y) => if calc.odd(x + y) { blue.lighten(50%) } else { blue.lighten(10%) },
+ inset: 5pt,
+ align: center,
+ gutter: 3pt,
+ table.cell(rowspan: 2, fill: orange)[*Left*],
+ [Right A], [Right A], [Right A],
+ [Right B], table.cell(colspan: 2, rowspan: 2, fill: orange.darken(10%))[B Wide],
+ [Left A], [Left A],
+ [Left B], [Left B], table.cell(colspan: 2, rowspan: 3, fill: orange)[Wide and Long]
+)
+
+---
+#set page(height: 10em)
+#set text(dir: rtl)
+#table(
+ columns: 2,
+ rows: (auto, auto, 3em),
+ row-gutter: 1em,
+ fill: red,
+ [a], table.cell(rowspan: 3, block(width: 50%, height: 10em, fill: orange) + place(bottom)[*ZD*]),
+ [e],
+ [f]
+)
diff --git a/tests/typ/layout/grid-stroke.typ b/tests/typ/layout/grid-stroke.typ
index 51b810d9..9d01e1cd 100644
--- a/tests/typ/layout/grid-stroke.typ
+++ b/tests/typ/layout/grid-stroke.typ
@@ -275,6 +275,20 @@
)
---
+// Test partial border line overrides
+#set page(width: auto, height: 7em, margin: (bottom: 1em))
+#table(
+ columns: 4,
+ stroke: (x, y) => if y == 0 or y == 4 { orange } else { aqua },
+ table.hline(stroke: blue, start: 1, end: 2), table.cell(stroke: red, v(3em)), table.cell(stroke: blue)[b], table.cell(stroke: green)[c], [M],
+ [a], [b], [c], [M],
+ [d], [e], [f], [M],
+ [g], [h], [i], [M],
+ table.cell(stroke: red)[a], table.cell(stroke: blue)[b], table.cell(stroke: green)[c], [M],
+ table.hline(stroke: blue, start: 1, end: 2),
+)
+
+---
// Error: 8:3-8:32 cannot place horizontal line at the 'bottom' position of the bottom border (y = 2)
// Hint: 8:3-8:32 set the line's position to 'top' or place it at a smaller 'y' index
#table(