diff --git a/crates/typst/src/foundations/styles.rs b/crates/typst/src/foundations/styles.rs index 9472e207a..fb8e56014 100644 --- a/crates/typst/src/foundations/styles.rs +++ b/crates/typst/src/foundations/styles.rs @@ -746,6 +746,34 @@ impl Fold for SmallVec<[T; N]> { } } +/// A variant of fold for foldable optional (`Option`) values where an inner +/// `None` value isn't respected (contrary to `Option`'s usual `Fold` +/// implementation, with which folding with an inner `None` always returns +/// `None`). Instead, when either of the `Option` objects is `None`, the other +/// one is necessarily returned by `fold_or`. Normal folding still occurs when +/// both values are `Some`, using `T`'s `Fold` implementation. +/// +/// This is useful when `None` in a particular context means "unspecified" +/// rather than "absent", in which case a specified value (`Some`) is chosen +/// over an unspecified one (`None`), while two specified values are folded +/// together. +pub trait AlternativeFold { + /// Attempts to fold this inner value with an outer value. However, if + /// either value is `None`, returns the other one instead of folding. + fn fold_or(self, outer: Self) -> Self; +} + +impl AlternativeFold for Option { + fn fold_or(self, outer: Self) -> Self { + match (self, outer) { + (Some(inner), Some(outer)) => Some(inner.fold(outer)), + // If one of values is `None`, return the other one instead of + // folding. + (inner, outer) => inner.or(outer), + } + } +} + /// A type that accumulates depth when folded. #[derive(Debug, Default, Clone, Copy, PartialEq, Hash)] pub struct Depth(pub usize); diff --git a/crates/typst/src/foundations/value.rs b/crates/typst/src/foundations/value.rs index 5da915d1e..b5f143d26 100644 --- a/crates/typst/src/foundations/value.rs +++ b/crates/typst/src/foundations/value.rs @@ -13,13 +13,14 @@ use crate::diag::StrResult; use crate::eval::ops; use crate::foundations::{ fields, repr, Args, Array, AutoValue, Bytes, CastInfo, Content, Datetime, Dict, - Duration, FromValue, Func, IntoValue, Label, Module, NativeElement, NativeType, - NoneValue, Plugin, Reflect, Repr, Scope, Str, Styles, Type, Version, + Duration, Fold, FromValue, Func, IntoValue, Label, Module, NativeElement, NativeType, + NoneValue, Plugin, Reflect, Repr, Resolve, Scope, Str, Styles, Type, Version, }; use crate::layout::{Abs, Angle, Em, Fr, Length, Ratio, Rel}; use crate::symbols::Symbol; use crate::syntax::{ast, Span}; use crate::text::{RawElem, TextElem}; +use crate::util::ArcExt; use crate::visualize::{Color, Gradient, Pattern}; /// A computational value. @@ -668,6 +669,53 @@ primitive! { Type: "type", Type } primitive! { Module: "module", Module } primitive! { Plugin: "plugin", Plugin } +impl Reflect for Arc { + fn input() -> CastInfo { + T::input() + } + + fn output() -> CastInfo { + T::output() + } + + fn castable(value: &Value) -> bool { + T::castable(value) + } + + fn error(found: &Value) -> EcoString { + T::error(found) + } +} + +impl IntoValue for Arc { + fn into_value(self) -> Value { + Arc::take(self).into_value() + } +} + +impl FromValue for Arc { + fn from_value(value: Value) -> StrResult { + match value { + v if T::castable(&v) => Ok(Arc::new(T::from_value(v)?)), + _ => Err(Self::error(&value)), + } + } +} + +impl Resolve for Arc { + type Output = Arc; + + fn resolve(self, styles: super::StyleChain) -> Self::Output { + Arc::new(Arc::take(self).resolve(styles)) + } +} + +impl Fold for Arc { + fn fold(self, outer: Self) -> Self { + Arc::new(Arc::take(self).fold(Arc::take(outer))) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/typst/src/layout/abs.rs b/crates/typst/src/layout/abs.rs index cb61e1b28..459f03709 100644 --- a/crates/typst/src/layout/abs.rs +++ b/crates/typst/src/layout/abs.rs @@ -4,7 +4,7 @@ use std::ops::{Add, Div, Mul, Neg, Rem}; use ecow::EcoString; -use crate::foundations::{cast, repr, Repr, Value}; +use crate::foundations::{cast, repr, Fold, Repr, Value}; use crate::util::{Numeric, Scalar}; /// An absolute length. @@ -227,6 +227,12 @@ impl<'a> Sum<&'a Self> for Abs { } } +impl Fold for Abs { + fn fold(self, _: Self) -> Self { + self + } +} + cast! { Abs, self => Value::Length(self.into()), diff --git a/crates/typst/src/layout/align.rs b/crates/typst/src/layout/align.rs index 934bfa194..c108ec898 100644 --- a/crates/typst/src/layout/align.rs +++ b/crates/typst/src/layout/align.rs @@ -321,6 +321,40 @@ cast! { } } +/// A horizontal alignment which only allows `left`/`right` and `start`/`end`, +/// thus excluding `center`. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)] +pub enum OuterHAlignment { + #[default] + Start, + Left, + Right, + End, +} + +impl From for HAlignment { + fn from(value: OuterHAlignment) -> Self { + match value { + OuterHAlignment::Start => Self::Start, + OuterHAlignment::Left => Self::Left, + OuterHAlignment::Right => Self::Right, + OuterHAlignment::End => Self::End, + } + } +} + +cast! { + OuterHAlignment, + self => HAlignment::from(self).into_value(), + align: Alignment => match align { + Alignment::H(HAlignment::Start) => Self::Start, + Alignment::H(HAlignment::Left) => Self::Left, + Alignment::H(HAlignment::Right) => Self::Right, + Alignment::H(HAlignment::End) => Self::End, + v => bail!("expected `start`, `left`, `right`, or `end`, found {}", v.repr()), + } +} + /// Where to align something vertically. #[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] pub enum VAlignment { @@ -383,6 +417,34 @@ cast! { } } +/// A vertical alignment which only allows `top` and `bottom`, thus excluding +/// `horizon`. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum OuterVAlignment { + #[default] + Top, + Bottom, +} + +impl From for VAlignment { + fn from(value: OuterVAlignment) -> Self { + match value { + OuterVAlignment::Top => Self::Top, + OuterVAlignment::Bottom => Self::Bottom, + } + } +} + +cast! { + OuterVAlignment, + self => VAlignment::from(self).into_value(), + align: Alignment => match align { + Alignment::V(VAlignment::Top) => Self::Top, + Alignment::V(VAlignment::Bottom) => Self::Bottom, + v => bail!("expected `top` or `bottom`, found {}", v.repr()), + } +} + /// A fixed alignment in the global coordinate space. /// /// For horizontal alignment, start is globally left and for vertical alignment diff --git a/crates/typst/src/layout/corners.rs b/crates/typst/src/layout/corners.rs index 85fd05032..3a2262bfc 100644 --- a/crates/typst/src/layout/corners.rs +++ b/crates/typst/src/layout/corners.rs @@ -2,7 +2,8 @@ use std::fmt::{self, Debug, Formatter}; use crate::diag::StrResult; use crate::foundations::{ - CastInfo, Dict, Fold, FromValue, IntoValue, Reflect, Resolve, StyleChain, Value, + AlternativeFold, CastInfo, Dict, Fold, FromValue, IntoValue, Reflect, Resolve, + StyleChain, Value, }; use crate::layout::Side; use crate::util::Get; @@ -240,13 +241,10 @@ impl Resolve for Corners { impl Fold for Corners> { fn fold(self, outer: Self) -> Self { - self.zip(outer).map(|(inner, outer)| match (inner, outer) { - (Some(inner), Some(outer)) => Some(inner.fold(outer)), - // Usually, folding an inner `None` with an `outer` preferres the - // explicit `None`. However, here `None` means unspecified and thus - // we want `outer`. - (inner, outer) => inner.or(outer), - }) + // Usually, folding an inner `None` with an `outer` preferres the + // explicit `None`. However, here `None` means unspecified and thus + // we want `outer`, so we use `fold_or` to opt into such behavior. + self.zip(outer).map(|(inner, outer)| inner.fold_or(outer)) } } diff --git a/crates/typst/src/layout/grid/layout.rs b/crates/typst/src/layout/grid/layout.rs index c17bbda53..790aedac7 100644 --- a/crates/typst/src/layout/grid/layout.rs +++ b/crates/typst/src/layout/grid/layout.rs @@ -1,13 +1,20 @@ +use std::fmt::Debug; +use std::hash::Hash; use std::num::NonZeroUsize; +use std::sync::Arc; use ecow::eco_format; +use super::lines::{ + generate_line_segments, hline_stroke_at_column, vline_stroke_at_row, Line, + LinePosition, LineSegment, +}; use crate::diag::{ bail, At, Hint, HintedStrResult, HintedString, SourceResult, StrResult, }; use crate::engine::Engine; use crate::foundations::{ - Array, CastInfo, Content, FromValue, Func, IntoValue, Reflect, Resolve, Smart, + Array, CastInfo, Content, Fold, FromValue, Func, IntoValue, Reflect, Resolve, Smart, StyleChain, Value, }; use crate::layout::{ @@ -17,7 +24,7 @@ use crate::layout::{ use crate::syntax::Span; use crate::text::TextElem; use crate::util::{MaybeReverseIter, NonZeroExt, Numeric}; -use crate::visualize::{FixedStroke, Geometry, Paint}; +use crate::visualize::{Geometry, Paint, Stroke}; /// A value that can be configured per cell. #[derive(Debug, Clone, PartialEq, Hash)] @@ -88,6 +95,65 @@ impl FromValue for Celled { } } +impl Fold for Celled { + fn fold(self, outer: Self) -> Self { + match (self, outer) { + (Self::Value(inner), Self::Value(outer)) => Self::Value(inner.fold(outer)), + (self_, _) => self_, + } + } +} + +impl Resolve for Celled { + type Output = ResolvedCelled; + + fn resolve(self, styles: StyleChain) -> Self::Output { + match self { + Self::Value(value) => ResolvedCelled(Celled::Value(value.resolve(styles))), + Self::Func(func) => ResolvedCelled(Celled::Func(func)), + Self::Array(values) => ResolvedCelled(Celled::Array( + values.into_iter().map(|value| value.resolve(styles)).collect(), + )), + } + } +} + +/// The result of resolving a Celled's value according to styles. +/// Holds resolved values which depend on each grid cell's position. +/// When it is a closure, however, it is only resolved when the closure is +/// called. +#[derive(Default, Clone)] +pub struct ResolvedCelled(Celled); + +impl ResolvedCelled +where + T: FromValue + Resolve, + ::Output: Default + Clone, +{ + /// Resolve the value based on the cell position. + pub fn resolve( + &self, + engine: &mut Engine, + styles: StyleChain, + x: usize, + y: usize, + ) -> SourceResult { + Ok(match &self.0 { + Celled::Value(value) => value.clone(), + Celled::Func(func) => func + .call(engine, [x, y])? + .cast::() + .at(func.span())? + .resolve(styles), + Celled::Array(array) => x + .checked_rem(array.len()) + .and_then(|i| array.get(i)) + .cloned() + .unwrap_or_default(), + }) + } +} + /// Represents a cell in CellGrid, to be laid out by GridLayouter. #[derive(Clone)] pub struct Cell { @@ -97,12 +163,31 @@ pub struct Cell { pub fill: Option, /// The amount of columns spanned by the cell. pub colspan: NonZeroUsize, + /// The cell's stroke. + /// + /// We use an Arc to avoid unnecessary space usage when all sides are the + /// same, or when the strokes come from a common source. + pub stroke: Sides>>>, + /// Which stroke sides were explicitly overridden by the cell, over the + /// grid's global stroke setting. + /// + /// This is used to define whether or not this cell's stroke sides should + /// have priority over adjacent cells' stroke sides, if those don't + /// 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, } impl From for Cell { /// Create a simple cell given its body. fn from(body: Content) -> Self { - Self { body, fill: None, colspan: NonZeroUsize::ONE } + Self { + body, + fill: None, + colspan: NonZeroUsize::ONE, + stroke: Sides::splat(None), + stroke_overridden: Sides::splat(false), + } } } @@ -119,7 +204,7 @@ impl LayoutMultiple for Cell { /// A grid entry. #[derive(Clone)] -enum Entry { +pub(super) enum Entry { /// An entry which holds a cell. Cell(Cell), /// An entry which is merged with another cell. @@ -139,12 +224,46 @@ impl Entry { } } +/// A grid item, possibly affected by automatic cell positioning. Can be either +/// a line or a cell. +pub enum GridItem { + /// A horizontal line in the grid. + HLine { + /// The row above which the horizontal line is drawn. + y: Smart, + start: usize, + end: Option, + stroke: Option>>, + /// The span of the corresponding line element. + span: Span, + /// The line's position. "before" here means on top of row 'y', while + /// "after" means below it. + position: LinePosition, + }, + /// A vertical line in the grid. + VLine { + /// The column before which the vertical line is drawn. + x: Smart, + start: usize, + end: Option, + stroke: Option>>, + /// The span of the corresponding line element. + span: Span, + /// The line's position. "before" here means to the left of column 'x', + /// while "after" means to its right (both considering LTR). + position: LinePosition, + }, + /// A cell in the grid. + Cell(T), +} + /// Used for cell-like elements which are aware of their final properties in /// the table, and may have property overrides. pub trait ResolvableCell { /// Resolves the cell's fields, given its coordinates and default grid-wide - /// fill, align and inset properties. + /// fill, align, inset and stroke properties. /// Returns a final Cell. + #[allow(clippy::too_many_arguments)] fn resolve_cell( self, x: usize, @@ -152,6 +271,7 @@ pub trait ResolvableCell { fill: &Option, align: Smart, inset: Sides>>, + stroke: Sides>>>>, styles: StyleChain, ) -> Cell; @@ -171,13 +291,21 @@ pub trait ResolvableCell { /// A grid of cells, including the columns, rows, and cell data. pub struct CellGrid { /// The grid cells. - entries: Vec, + pub(super) entries: Vec, /// The column tracks including gutter tracks. - cols: Vec, + pub(super) cols: Vec, /// The row tracks including gutter tracks. - rows: Vec, + pub(super) rows: Vec, + /// The vertical lines before each column, or on the end border. + /// Gutter columns are not included. + /// Contains up to 'cols_without_gutter.len() + 1' vectors of lines. + pub(super) vlines: Vec>, + /// The horizontal lines on top of each row, or on the bottom border. + /// Gutter rows are not included. + /// Contains up to 'rows_without_gutter.len() + 1' vectors of lines. + pub(super) hlines: Vec>, /// Whether this grid has gutters. - has_gutter: bool, + pub(super) has_gutter: bool, } impl CellGrid { @@ -188,7 +316,7 @@ impl CellGrid { cells: impl IntoIterator, ) -> Self { let entries = cells.into_iter().map(Entry::Cell).collect(); - Self::new_internal(tracks, gutter, entries) + Self::new_internal(tracks, gutter, vec![], vec![], entries) } /// Resolves and positions all cells in the grid before creating it. @@ -198,20 +326,37 @@ impl CellGrid { /// must implement Default in order to fill positions in the grid which /// weren't explicitly specified by the user with empty cells. #[allow(clippy::too_many_arguments)] - pub fn resolve( + pub fn resolve( tracks: Axes<&[Sizing]>, gutter: Axes<&[Sizing]>, - cells: &[T], + items: I, fill: &Celled>, align: &Celled>, inset: Sides>>, + stroke: &ResolvedCelled>>>>, engine: &mut Engine, styles: StyleChain, span: Span, - ) -> SourceResult { + ) -> SourceResult + where + T: ResolvableCell + Default, + I: IntoIterator>, + I::IntoIter: ExactSizeIterator, + { // Number of content columns: Always at least one. let c = tracks.x.len().max(1); + // Lists of lines. + // Horizontal lines are only pushed later to be able to check for row + // validity, since the amount of rows isn't known until all items were + // analyzed in the for loop below. + // We keep their spans so we can report errors later. + let mut pending_hlines: Vec<(Span, Line)> = vec![]; + + // For consistency, only push vertical lines later as well. + let mut pending_vlines: Vec<(Span, Line)> = vec![]; + let has_gutter = gutter.any(|tracks| !tracks.is_empty()); + // 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 @@ -219,23 +364,92 @@ impl CellGrid { // Therefore, we use a counter, 'auto_index', to determine the position // of the next cell with (x: auto, y: auto). It is only stepped when // a cell with (x: auto, y: auto), usually the vast majority, is found. - let mut auto_index = 0; + let mut auto_index: usize = 0; // We have to rebuild the grid to account for arbitrary positions. - // Create at least 'cells.len()' positions, since there will be at - // least 'cells.len()' cells, even though some of them might be placed - // in arbitrary positions and thus cause the grid to expand. + // Create at least 'items.len()' positions, since there could be at + // least 'items.len()' cells (if no explicit lines were specified), + // even though some of them might be placed in arbitrary positions and + // thus cause the grid to expand. // Additionally, make sure we allocate up to the next multiple of 'c', // since each row will have 'c' cells, even if the last few cells // weren't explicitly specified by the user. // We apply '% c' twice so that the amount of cells potentially missing - // is zero when 'cells.len()' is already a multiple of 'c' (thus - // 'cells.len() % c' would be zero). - let Some(cell_count) = cells.len().checked_add((c - cells.len() % c) % c) else { - bail!(span, "too many cells were given") + // is zero when 'items.len()' is already a multiple of 'c' (thus + // 'items.len() % c' would be zero). + let items = items.into_iter(); + let Some(item_count) = items.len().checked_add((c - items.len() % c) % c) else { + bail!(span, "too many cells or lines were given") }; - let mut resolved_cells: Vec> = Vec::with_capacity(cell_count); - for cell in cells.iter().cloned() { + let mut resolved_cells: Vec> = Vec::with_capacity(item_count); + for item in items { + let cell = match item { + GridItem::HLine { y, start, end, stroke, span, position } => { + let y = y.unwrap_or_else(|| { + // When no 'y' is specified for the hline, we place it + // under the latest automatically positioned cell. + // The current value of the auto index is always the + // index of the latest automatically positioned cell + // placed plus one (that's what we do in + // 'resolve_cell_position'), so we subtract 1 to get + // that cell's index, and place the hline below its + // row. The exception is when the auto_index is 0, + // meaning no automatically positioned cell was placed + // yet. In that case, we place the hline at the top of + // the table. + auto_index + .checked_sub(1) + .map_or(0, |last_auto_index| last_auto_index / c + 1) + }); + if end.is_some_and(|end| end.get() < start) { + bail!(span, "line cannot end before it starts"); + } + let line = Line { index: y, start, end, stroke, position }; + + // Since the amount of rows is dynamic, delay placing + // hlines until after all cells were placed so we can + // properly verify if they are valid. Note that we can't + // place hlines even if we already know they would be in a + // valid row, since it's possible that we pushed pending + // hlines in the same row as this one in previous + // iterations, and we need to ensure that hlines from + // previous iterations are pushed to the final vector of + // hlines first - the order of hlines must be kept, as this + // matters when determining which one "wins" in case of + // conflict. Pushing the current hline before we push + // pending hlines later would change their order! + pending_hlines.push((span, line)); + continue; + } + GridItem::VLine { x, start, end, stroke, span, position } => { + let x = x.unwrap_or_else(|| { + // When no 'x' is specified for the vline, we place it + // after the latest automatically positioned cell. + // The current value of the auto index is always the + // index of the latest automatically positioned cell + // placed plus one (that's what we do in + // 'resolve_cell_position'), so we subtract 1 to get + // that cell's index, and place the vline after its + // column. The exception is when the auto_index is 0, + // meaning no automatically positioned cell was placed + // yet. In that case, we place the vline to the left of + // the table. + auto_index + .checked_sub(1) + .map_or(0, |last_auto_index| last_auto_index % c + 1) + }); + if end.is_some_and(|end| end.get() < start) { + bail!(span, "line cannot end before it starts"); + } + let line = Line { index: x, start, end, stroke, position }; + + // For consistency with hlines, we only push vlines to the + // final vector of vlines after processing every cell. + pending_vlines.push((span, line)); + continue; + } + GridItem::Cell(cell) => cell, + }; let cell_span = cell.span(); // Let's calculate the cell's final position based on its // requested position. @@ -273,6 +487,7 @@ impl CellGrid { &fill.resolve(engine, x, y)?, align.resolve(engine, x, y)?, inset, + stroke.resolve(engine, styles, x, y)?, styles, ); @@ -358,6 +573,7 @@ impl CellGrid { &fill.resolve(engine, x, y)?, align.resolve(engine, x, y)?, inset, + stroke.resolve(engine, styles, x, y)?, styles, ); Ok(Entry::Cell(new_cell)) @@ -365,13 +581,97 @@ impl CellGrid { }) .collect::>>()?; - Ok(Self::new_internal(tracks, gutter, resolved_cells)) + // Populate the final lists of lines. + // For each line type (horizontal or vertical), we keep a vector for + // every group of lines with the same index. + let mut vlines: Vec> = vec![]; + let mut hlines: Vec> = vec![]; + let row_amount = resolved_cells.len().div_ceil(c); + + for (line_span, line) in pending_hlines { + let y = line.index; + if y > row_amount { + bail!(line_span, "cannot place horizontal line at invalid row {y}"); + } + if y == row_amount && line.position == LinePosition::After { + bail!( + line_span, + "cannot place horizontal line at the 'bottom' position of the bottom border (y = {y})"; + hint: "set the line's position to 'top' or place it at a smaller 'y' index" + ); + } + let line = if line.position == LinePosition::After + && (!has_gutter || y + 1 == row_amount) + { + // Just place the line on top of the next row if + // there's no gutter and the line should be placed + // after the one with given index. + // + // Note that placing after the last row is also the same as + // just placing on the grid's bottom border, even with + // gutter. + Line { + index: y + 1, + position: LinePosition::Before, + ..line + } + } else { + line + }; + let y = line.index; + + if hlines.len() <= y { + hlines.resize_with(y + 1, Vec::new); + } + hlines[y].push(line); + } + + for (line_span, line) in pending_vlines { + let x = line.index; + if x > c { + bail!(line_span, "cannot place vertical line at invalid column {x}"); + } + if x == c && line.position == LinePosition::After { + bail!( + line_span, + "cannot place vertical line at the 'end' position of the end border (x = {c})"; + hint: "set the line's position to 'start' or place it at a smaller 'x' index" + ); + } + let line = + if line.position == LinePosition::After && (!has_gutter || x + 1 == c) { + // Just place the line before the next column if + // there's no gutter and the line should be placed + // after the one with given index. + // + // Note that placing after the last column is also the + // same as just placing on the grid's end border, even + // with gutter. + Line { + index: x + 1, + position: LinePosition::Before, + ..line + } + } else { + line + }; + let x = line.index; + + if vlines.len() <= x { + vlines.resize_with(x + 1, Vec::new); + } + vlines[x].push(line); + } + + Ok(Self::new_internal(tracks, gutter, vlines, hlines, resolved_cells)) } /// Generates the cell grid, given the tracks and resolved entries. - fn new_internal( + pub(super) fn new_internal( tracks: Axes<&[Sizing]>, gutter: Axes<&[Sizing]>, + vlines: Vec>, + hlines: Vec>, entries: Vec, ) -> Self { let mut cols = vec![]; @@ -418,14 +718,14 @@ impl CellGrid { rows.pop(); } - Self { cols, rows, entries, has_gutter } + Self { cols, rows, entries, vlines, hlines, has_gutter } } /// Get the grid entry in column `x` and row `y`. /// /// Returns `None` if it's a gutter cell. #[track_caller] - fn entry(&self, x: usize, y: usize) -> Option<&Entry> { + pub(super) fn entry(&self, x: usize, y: usize) -> Option<&Entry> { assert!(x < self.cols.len()); assert!(y < self.rows.len()); @@ -447,19 +747,29 @@ impl CellGrid { /// /// Returns `None` if it's a gutter cell or merged position. #[track_caller] - fn cell(&self, x: usize, y: usize) -> Option<&Cell> { + pub(super) fn cell(&self, x: usize, y: usize) -> Option<&Cell> { 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. - /// If the entry at the given position is a cell, returns the given + /// - If the entry at the given position is a cell, returns the given /// position. - /// If it is a merged cell, returns the parent cell's position. - /// If it is a gutter cell, returns None. + /// - If it is a merged cell, returns the parent cell's position. + /// - If it is a gutter cell, returns None. #[track_caller] - fn parent_cell_position(&self, x: usize, y: usize) -> Option> { + pub(super) fn parent_cell_position(&self, x: usize, y: usize) -> Option> { self.entry(x, y).map(|entry| match entry { Entry::Cell(_) => Axes::new(x, y), Entry::Merged { parent } => { @@ -571,8 +881,6 @@ fn resolve_cell_position( pub struct GridLayouter<'a> { /// The grid of cells. grid: &'a CellGrid, - // How to stroke the cells. - stroke: &'a Option, /// The regions to layout children into. regions: Regions<'a>, /// The inherited styles. @@ -617,10 +925,8 @@ impl<'a> GridLayouter<'a> { /// Create a new grid layouter. /// /// This prepares grid layout by unifying content and gutter tracks. - #[allow(clippy::too_many_arguments)] pub fn new( grid: &'a CellGrid, - stroke: &'a Option, regions: Regions<'a>, styles: StyleChain<'a>, span: Span, @@ -632,7 +938,6 @@ impl<'a> GridLayouter<'a> { Self { grid, - stroke, regions, styles, rcols: vec![Abs::zero(); grid.cols.len()], @@ -677,42 +982,161 @@ impl<'a> GridLayouter<'a> { continue; } - // Render table lines. - if let Some(stroke) = self.stroke { - let thickness = stroke.thickness; - let half = thickness / 2.0; + // Render grid lines. + // We collect lines into a vector before rendering so we can sort + // them based on thickness, such that the lines with largest + // thickness are drawn on top; and also so we can prepend all of + // them at once in the frame, as calling prepend() for each line, + // and thus pushing all frame items forward each time, would result + // in quadratic complexity. + let mut lines = vec![]; - // Render horizontal lines. - for offset in points(rows.iter().map(|piece| piece.height)) { - let target = Point::with_x(frame.width() + thickness); - let hline = Geometry::Line(target).stroked(stroke.clone()); - frame.prepend( - Point::new(-half, offset), - FrameItem::Shape(hline, self.span), - ); - } + // Render vertical lines. + // Render them first so horizontal lines have priority later. + for (x, dx) in points(self.rcols.iter().copied()).enumerate() { + let dx = if self.is_rtl { self.width - dx } else { dx }; + let is_end_border = x == self.grid.cols.len(); + let vlines_at_column = self + .grid + .vlines + .get(if !self.grid.has_gutter { + x + } else if is_end_border { + // The end border has its own vector of lines, but + // dividing it by 2 and flooring would give us the + // vector of lines with the index of the last column. + // Add 1 so we get the border's lines. + x / 2 + 1 + } else { + // If x is a gutter column, this will round down to the + // index of the previous content column, which is + // intentional - the only lines which can appear before + // a gutter column are lines for the previous column + // marked with "LinePosition::After". Therefore, we get + // the previous column's lines. Worry not, as + // 'generate_line_segments' will correctly filter lines + // based on their LinePosition for us. + // + // If x is a content column, this will correctly return + // its index before applying gutters, so nothing + // special here (lines with "LinePosition::After" would + // then be ignored for this column, as we are drawing + // lines before it, not after). + x / 2 + }) + .map(|vlines| &**vlines) + .unwrap_or(&[]); + let tracks = rows.iter().map(|row| (row.y, row.height)); - // Render vertical lines. - for (x, dx) in points(self.rcols.iter().copied()).enumerate() { - let dx = if self.is_rtl { self.width - dx } else { dx }; - // We want each vline to span the entire table (start - // at y = 0, end after all rows). - // We use 'split_vline' to split the vline such that it - // is not drawn above colspans. - for (dy, length) in - split_vline(self.grid, rows, x, 0, self.grid.rows.len()) - { - let target = Point::with_y(length + thickness); - let vline = Geometry::Line(target).stroked(stroke.clone()); - frame.prepend( - Point::new(dx, dy - half), - FrameItem::Shape(vline, self.span), - ); - } - } + // Determine all different line segments we have to draw in + // this column, and convert them to points and shapes. + // + // Even a single, uniform line might generate more than one + // segment, if it happens to cross a colspan (over which it + // must not be drawn). + let segments = generate_line_segments( + self.grid, + tracks, + x, + vlines_at_column, + is_end_border, + vline_stroke_at_row, + ) + .map(|segment| { + let LineSegment { stroke, offset: dy, length, priority } = segment; + let stroke = (*stroke).clone().unwrap_or_default(); + let thickness = stroke.thickness; + let half = thickness / 2.0; + let target = Point::with_y(length + thickness); + let vline = Geometry::Line(target).stroked(stroke); + ( + thickness, + priority, + Point::new(dx, dy - half), + FrameItem::Shape(vline, self.span), + ) + }); + + lines.extend(segments); } + // Render horizontal lines. + // They are rendered second as they default to appearing on top. + // First, calculate their offsets from the top of the frame. + let hline_offsets = points(rows.iter().map(|piece| piece.height)); + + // 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)) + .chain(std::iter::once(self.grid.rows.len())); + + for (y, dy) in hline_indices.zip(hline_offsets) { + let is_bottom_border = y == self.grid.rows.len(); + let hlines_at_row = self + .grid + .hlines + .get(if !self.grid.has_gutter { + y + } else if is_bottom_border { + y / 2 + 1 + } else { + // Check the vlines loop for an explanation regarding + // these index operations. + y / 2 + }) + .map(|hlines| &**hlines) + .unwrap_or(&[]); + let tracks = self.rcols.iter().copied().enumerate(); + + // Determine all different line segments we have to draw in + // this row, and convert them to points and shapes. + let segments = generate_line_segments( + self.grid, + tracks, + y, + hlines_at_row, + is_bottom_border, + hline_stroke_at_column, + ) + .map(|segment| { + let LineSegment { stroke, offset: dx, length, priority } = segment; + let stroke = (*stroke).clone().unwrap_or_default(); + let thickness = stroke.thickness; + let half = thickness / 2.0; + let dx = if self.is_rtl { self.width - dx - length } else { dx }; + let target = Point::with_x(length + thickness); + let hline = Geometry::Line(target).stroked(stroke); + ( + thickness, + priority, + Point::new(dx - half, dy), + FrameItem::Shape(hline, self.span), + ) + }); + + // Draw later (after we sort all lines below.) + lines.extend(segments); + } + + // Sort by increasing thickness, so that we draw larger strokes + // on top. When the thickness is the same, sort by priority. + // + // Sorting by thickness avoids layering problems where a smaller + // hline appears "inside" a larger vline. When both have the same + // size, hlines are drawn on top (since the sort is stable, and + // they are pushed later). + lines.sort_by_key(|(thickness, priority, ..)| (*thickness, *priority)); + // Render cell backgrounds. + // We collect them into a vector so they can all be prepended at + // once to the frame, together with lines. + let mut fills = vec![]; + // Reverse with RTL so that later columns start first. let mut dx = Abs::zero(); for (x, &col) in self.rcols.iter().enumerate().rev_if(self.is_rtl) { @@ -736,13 +1160,22 @@ impl<'a> GridLayouter<'a> { let pos = Point::new(dx + offset, dy); let size = Size::new(width, row.height); let rect = Geometry::Rect(size).filled(fill); - frame.prepend(pos, FrameItem::Shape(rect, self.span)); + fills.push((pos, FrameItem::Shape(rect, self.span))); } } dy += row.height; } dx += col; } + + // Now we render each fill and stroke by prepending to the frame, + // such that both appear below cell contents. Fills come first so + // that they appear below lines. + frame.prepend_multiple( + fills + .into_iter() + .chain(lines.into_iter().map(|(_, _, point, shape)| (point, shape))), + ); } Ok(Fragment::frames(finished)) @@ -1234,287 +1667,3 @@ fn points(extents: impl IntoIterator) -> impl Iterator { offset }) } - -/// Given the 'x' of the column right after the vline (or cols.len() at the -/// border) and its start..end range of rows, alongside the rows for the -/// current region, splits the vline into contiguous parts to draw, including -/// the height of the vline in each part. This will go through each row and -/// interrupt the current vline to be drawn when a colspan is detected, or the -/// end of the row range (or of the region) is reached. -/// The idea is to not draw vlines over colspans. -/// This will return the start offsets and lengths of each final segment of -/// this vline. The offsets are relative to the top of the first row. -/// Note that this assumes that rows are sorted according to ascending 'y'. -fn split_vline( - grid: &CellGrid, - rows: &[RowPiece], - x: usize, - start: usize, - end: usize, -) -> impl IntoIterator { - // Each segment of this vline that should be drawn. - // The last element in the vector below is the currently drawn segment. - // That is, the last segment will be expanded until interrupted. - let mut drawn_vlines = vec![]; - // Whether the latest vline segment is complete, because we hit a row we - // should skip while drawing the vline. Starts at true so we push - // the first segment to the vector. - let mut interrupted = true; - // How far down from the first row have we gone so far. - // Used to determine the positions at which to draw each segment. - let mut offset = Abs::zero(); - - // We start drawing at the first suitable row, and keep going down - // (increasing y) expanding the last segment until we hit a row on top of - // which we shouldn't draw, which is skipped, leading to the creation of a - // new vline segment later if a suitable row is found, restarting the - // cycle. - for row in rows.iter().take_while(|row| row.y < end) { - if should_draw_vline_at_row(grid, x, row.y, start, end) { - if interrupted { - // Last segment was interrupted by a colspan, or there are no - // segments yet. - // Create a new segment to draw. We start spanning this row. - drawn_vlines.push((offset, row.height)); - interrupted = false; - } else { - // Extend the current segment so it covers at least this row - // as well. - // The vector can't be empty if interrupted is false. - let current_segment = drawn_vlines.last_mut().unwrap(); - current_segment.1 += row.height; - } - } else { - interrupted = true; - } - offset += row.height; - } - - drawn_vlines -} - -/// Returns 'true' if the vline right before column 'x', given its start..end -/// range of rows, should be drawn when going through row 'y'. -/// That only occurs if the row is within its start..end range, and if it -/// wouldn't go through a colspan. -fn should_draw_vline_at_row( - grid: &CellGrid, - x: usize, - y: usize, - start: usize, - end: usize, -) -> bool { - if !(start..end).contains(&y) { - // Row is out of range for this line - return false; - } - if x == 0 || x == grid.cols.len() { - // Border vline. Always drawn. - return true; - } - // 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(); - - parent_x >= x || parent_y > y -} - -#[cfg(test)] -mod test { - use super::*; - - fn sample_cell() -> Cell { - Cell { - body: Content::default(), - fill: None, - colspan: NonZeroUsize::ONE, - } - } - - fn cell_with_colspan(colspan: usize) -> Cell { - Cell { - body: Content::default(), - fill: None, - colspan: NonZeroUsize::try_from(colspan).unwrap(), - } - } - - fn sample_grid(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::Merged { parent: 2 }, - // row 1 - Entry::Cell(sample_cell()), - Entry::Cell(cell_with_colspan(3)), - 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::Merged { parent: 10 }, - // row 3 - Entry::Cell(sample_cell()), - Entry::Cell(cell_with_colspan(3)), - Entry::Merged { parent: 13 }, - Entry::Merged { parent: 13 }, - // row 4 - Entry::Cell(sample_cell()), - Entry::Merged { parent: 13 }, - Entry::Merged { parent: 13 }, - Entry::Merged { parent: 13 }, - // row 5 - Entry::Cell(sample_cell()), - Entry::Cell(sample_cell()), - Entry::Cell(cell_with_colspan(2)), - Entry::Merged { parent: 22 }, - ]; - CellGrid::new_internal( - Axes::with_x(&[Sizing::Auto; COLS]), - if gutters { - Axes::new(&[Sizing::Auto; COLS - 1], &[Sizing::Auto; ROWS - 1]) - } else { - Axes::default() - }, - entries, - ) - } - - #[test] - fn test_vline_splitting_without_gutter() { - let grid = sample_grid(false); - let rows = &[ - RowPiece { height: Abs::pt(1.0), y: 0 }, - RowPiece { height: Abs::pt(2.0), y: 1 }, - RowPiece { height: Abs::pt(4.0), y: 2 }, - RowPiece { height: Abs::pt(8.0), y: 3 }, - RowPiece { height: Abs::pt(16.0), y: 4 }, - RowPiece { height: Abs::pt(32.0), y: 5 }, - ]; - let expected_vline_splits = &[ - vec![(Abs::pt(0.), Abs::pt(1. + 2. + 4. + 8. + 16. + 32.))], - vec![(Abs::pt(0.), Abs::pt(1. + 2. + 4. + 8. + 16. + 32.))], - // interrupted a few times by colspans - vec![ - (Abs::pt(0.), Abs::pt(1.)), - (Abs::pt(1. + 2.), Abs::pt(4.)), - (Abs::pt(1. + 2. + 4. + 8. + 16.), Abs::pt(32.)), - ], - // interrupted every time by colspans - vec![], - vec![(Abs::pt(0.), Abs::pt(1. + 2. + 4. + 8. + 16. + 32.))], - ]; - for (x, expected_splits) in expected_vline_splits.iter().enumerate() { - assert_eq!( - expected_splits, - &split_vline(&grid, rows, x, 0, 6).into_iter().collect::>(), - ); - } - } - - #[test] - fn test_vline_splitting_with_gutter() { - let grid = sample_grid(true); - let rows = &[ - RowPiece { height: Abs::pt(1.0), y: 0 }, - RowPiece { height: Abs::pt(2.0), y: 1 }, - RowPiece { height: Abs::pt(4.0), y: 2 }, - RowPiece { height: Abs::pt(8.0), y: 3 }, - RowPiece { height: Abs::pt(16.0), y: 4 }, - RowPiece { height: Abs::pt(32.0), y: 5 }, - RowPiece { height: Abs::pt(64.0), y: 6 }, - RowPiece { height: Abs::pt(128.0), y: 7 }, - RowPiece { height: Abs::pt(256.0), y: 8 }, - RowPiece { height: Abs::pt(512.0), y: 9 }, - RowPiece { height: Abs::pt(1024.0), y: 10 }, - ]; - let expected_vline_splits = &[ - // left border - vec![( - Abs::pt(0.), - Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024.), - )], - // gutter line below - vec![( - Abs::pt(0.), - Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024.), - )], - vec![( - Abs::pt(0.), - Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024.), - )], - // gutter line below - // the two lines below are interrupted multiple times by colspans - vec![ - (Abs::pt(0.), Abs::pt(1. + 2.)), - (Abs::pt(1. + 2. + 4.), Abs::pt(8. + 16. + 32.)), - ( - Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.), - Abs::pt(512. + 1024.), - ), - ], - vec![ - (Abs::pt(0.), Abs::pt(1. + 2.)), - (Abs::pt(1. + 2. + 4.), Abs::pt(8. + 16. + 32.)), - ( - Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.), - Abs::pt(512. + 1024.), - ), - ], - // gutter line below - // the two lines below can only cross certain gutter rows, because - // all non-gutter cells in the following column are merged with - // cells from the previous column. - vec![ - (Abs::pt(1.), Abs::pt(2.)), - (Abs::pt(1. + 2. + 4.), Abs::pt(8.)), - (Abs::pt(1. + 2. + 4. + 8. + 16.), Abs::pt(32.)), - ( - Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.), - Abs::pt(512.), - ), - ], - vec![ - (Abs::pt(1.), Abs::pt(2.)), - (Abs::pt(1. + 2. + 4.), Abs::pt(8.)), - (Abs::pt(1. + 2. + 4. + 8. + 16.), Abs::pt(32.)), - ( - Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.), - Abs::pt(512.), - ), - ], - // right border - vec![( - Abs::pt(0.), - Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024.), - )], - ]; - for (x, expected_splits) in expected_vline_splits.iter().enumerate() { - assert_eq!( - expected_splits, - &split_vline(&grid, rows, x, 0, 11).into_iter().collect::>(), - ); - } - } -} diff --git a/crates/typst/src/layout/grid/lines.rs b/crates/typst/src/layout/grid/lines.rs new file mode 100644 index 000000000..9a4da26d3 --- /dev/null +++ b/crates/typst/src/layout/grid/lines.rs @@ -0,0 +1,1096 @@ +use std::num::NonZeroUsize; +use std::sync::Arc; + +use super::layout::CellGrid; +use crate::foundations::{AlternativeFold, Fold}; +use crate::layout::{Abs, Axes}; +use crate::visualize::Stroke; + +/// Represents an explicit grid line (horizontal or vertical) specified by the +/// user. +pub struct Line { + /// The index of the track after this line. This will be the index of the + /// row a horizontal line is above of, or of the column right after a + /// vertical line. + /// + /// Must be within `0..=tracks.len()` (where `tracks` is either `grid.cols` + /// or `grid.rows`, ignoring gutter tracks, as appropriate). + pub index: usize, + /// The index of the track at which this line starts being drawn. + /// This is the first column a horizontal line appears in, or the first row + /// a vertical line appears in. + /// + /// Must be within `0..tracks.len()` minus gutter tracks. + pub start: usize, + /// The index after the last track through which the line is drawn. + /// Thus, the line is drawn through tracks `start..end` (note that `end` is + /// exclusive). + /// + /// Must be within `1..=tracks.len()` minus gutter tracks. + /// `None` indicates the line should go all the way to the end. + pub end: Option, + /// The line's stroke. This is `None` when the line is explicitly used to + /// override a previously specified line. + pub stroke: Option>>, + /// The line's position in relation to the track with its index. + pub position: LinePosition, +} + +/// Indicates whether the line should be drawn before or after the track with +/// its index. This is mostly only relevant when gutter is used, since, then, +/// the position after a track is not the same as before the next +/// non-gutter track. +#[derive(PartialEq, Eq)] +pub enum LinePosition { + /// The line should be drawn before its track (e.g. hline on top of a row). + Before, + /// The line should be drawn after its track (e.g. hline below a row). + After, +} + +/// Indicates which priority a particular grid line segment should have, based +/// on the highest priority configuration that defined the segment's stroke. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum StrokePriority { + /// The stroke of the segment was derived solely from the grid's global + /// stroke setting, so it should have the lowest priority. + GridStroke = 0, + /// The segment's stroke was derived (even if partially) from a cell's + /// stroke override, so it should have priority over non-overridden cell + /// strokes and be drawn on top of them (when they have the same + /// thickness). + CellStroke = 1, + /// The segment's stroke was derived from a user's explicitly placed line + /// (hline or vline), and thus should have maximum priority, drawn on top + /// of any cell strokes (when they have the same thickness). + ExplicitLine = 2, +} + +/// Data for a particular line segment in the grid as generated by +/// 'generate_line_segments'. +#[derive(Debug, PartialEq, Eq)] +pub(super) struct LineSegment { + /// The stroke with which to draw this segment. + pub(super) stroke: Arc>, + /// The offset of this segment since the beginning of its axis. + /// For a vertical line segment, this is the offset since the top of the + /// table in the current page; for a horizontal line segment, this is the + /// offset since the start border of the table. + pub(super) offset: Abs, + /// The length of this segment. + pub(super) length: Abs, + /// The segment's drawing priority, indicating on top of which other + /// segments this one should be drawn. + pub(super) priority: StrokePriority, +} + +/// Generates the segments of lines that should be drawn alongside a certain +/// axis in the grid, going through the given tracks (orthogonal to the lines). +/// Each returned segment contains its stroke, its offset from the start, and +/// its length. +/// +/// Accepts, as parameters, the index of the lines that should be produced +/// (for example, the column at which vertical lines will be drawn); a list of +/// user-specified lines with the same index (the `lines` parameter); whether +/// the given index corresponds to the maximum index for the line's axis; and a +/// function which returns the final stroke that should be used for each track +/// the line goes through, alongside the priority of the returned stroke (its +/// parameters are the grid, the index of the line to be drawn, the number of +/// the track to draw at and the stroke of the user hline/vline override at +/// 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 +/// 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. +/// +/// Regarding priority, the function should return a priority of ExplicitLine +/// when the user-defined line's stroke at the current position isn't None +/// (note that it is passed by parameter to the function). When it is None, the +/// function should return a priority of CellStroke if the stroke returned was +/// given or affected by a per-cell override of the grid's global stroke. +/// When that isn't the case, the returned stroke was entirely provided by the +/// grid's global stroke, and thus a priority of GridStroke should be returned. +/// +/// 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 +/// current region, as pairs (row index, row height). +pub(super) fn generate_line_segments<'grid, F, I>( + grid: &'grid CellGrid, + tracks: I, + index: usize, + lines: &'grid [Line], + is_max_index: bool, + line_stroke_at_track: F, +) -> impl Iterator + 'grid +where + F: Fn( + &CellGrid, + usize, + usize, + Option>>, + ) -> Option<(Arc>, StrokePriority)> + + 'grid, + I: IntoIterator, + I::IntoIter: 'grid, +{ + // The segment currently being drawn. + // + // It is extended for each consecutive track through which the line would + // be drawn with the same stroke and priority. + // + // Starts as None to force us to create a new segment as soon as we find + // the first track through which we should draw. + let mut current_segment: Option = None; + + // How far from the start (before the first track) have we gone so far. + // Used to determine the positions at which to draw each segment. + let mut offset = Abs::zero(); + + // How much to multiply line indices by to account for gutter. + let gutter_factor = if grid.has_gutter { 2 } else { 1 }; + + // Which line position to look for in the given list of lines. + // + // If the index represents a gutter track, this means the list of lines + // parameter will actually correspond to the list of lines in the previous + // index, so we must look for lines positioned after the previous index, + // and not before, to determine which lines should be placed in gutter. + // + // 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 { + LinePosition::After + } else { + LinePosition::Before + }; + + // Create an iterator of line segments, which will go through each track, + // from start to finish, to create line segments and extend them until they + // are interrupted and thus yielded through the iterator. We then repeat + // the process, picking up from the track after the one at which we had + // an interruption, until we have gone through all tracks. + // + // When going through each track, we check if the current segment would be + // interrupted, either because, at this track, we hit a merged cell over + // which we shouldn't draw, or because the line would have a different + // stroke or priority at this point (so we have to start a new segment). If + // so, the current segment is yielded and its variable is either set to + // 'None' (if no segment should be drawn at the point of interruption, + // meaning we might have to create a new segment later) or to the new + // segment (if we're starting to draw a segment with a different stroke or + // priority than before). + // Otherwise (if the current segment should span the current track), it is + // simply extended (or a new one is created, if it is 'None'), and no value + // is yielded for the current track, since the segment isn't yet complete + // (the next tracks might extend it further before it is interrupted and + // yielded). That is, we yield each segment only when it is interrupted, + // since then we will know its final length for sure. + // + // After the loop is done (and thus we went through all tracks), we + // 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(); + 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 + // interrupted. The for loop is resumed from where it stopped at the + // next call due to that, ensuring we go through all tracks and then + // stop. + for (track, size) in &mut tracks { + // Get the expected line stroke at this track by folding the + // strokes of each user-specified line (with priority to the + // user-specified line specified last). + let stroke = lines + .iter() + .filter(|line| { + line.position == expected_line_position + && line + .end + .map(|end| { + // Subtract 1 from end index so we stop at the last + // cell before it (don't cross one extra gutter). + let end = if grid.has_gutter { + 2 * end.get() - 1 + } else { + end.get() + }; + (gutter_factor * line.start..end).contains(&track) + }) + .unwrap_or_else(|| track >= gutter_factor * line.start) + }) + .map(|line| line.stroke.clone()) + .fold(None, |acc, line_stroke| line_stroke.fold(acc)); + + // The function shall determine if it is appropriate to draw + // the line at this position or not (i.e. whether or not it + // would cross a merged cell), and, if so, the final stroke it + // should have (because cells near this position could have + // stroke overrides, which have priority and should be folded + // with the stroke obtained above). + // + // If we are currently already drawing a segment and the function + // indicates we should, at this track, draw some other segment + // (with a different stroke or priority), or even no segment at + // all, we interrupt and yield the current segment (which was drawn + // up to the previous track) by returning it wrapped in 'Some()' + // (which indicates, in the context of 'std::iter::from_fn', that + // our iterator isn't over yet, and this should be its next value). + if let Some((stroke, priority)) = + line_stroke_at_track(grid, index, track, stroke) + { + // We should draw at this position. Let's check if we were + // already drawing in the previous position. + if let Some(current_segment) = &mut current_segment { + // We are currently building a segment. Let's check if + // we should extend it to this track as well. + if current_segment.stroke == stroke + && current_segment.priority == priority + { + // Extend the current segment so it covers at least + // this track as well, since we should use the same + // stroke as in the previous one when a line goes + // through this track, with the same priority. + current_segment.length += size; + } else { + // We got a different stroke or priority now, so create + // a new segment with the new stroke and spanning the + // current track. Yield the old segment, as it was + // interrupted and is thus complete. + let new_segment = + LineSegment { stroke, offset, length: size, priority }; + let old_segment = std::mem::replace(current_segment, new_segment); + offset += size; + return Some(old_segment); + } + } else { + // We should draw here, but there is no segment + // currently being drawn, either because the last + // position had a merged cell, had a stroke + // of 'None', or because this is the first track. + // Create a new segment to draw. We start spanning this + // track. + current_segment = + Some(LineSegment { stroke, offset, length: size, priority }); + } + } else if let Some(old_segment) = current_segment.take() { + // We shouldn't draw here (stroke of None), so we yield the + // current segment, as it was interrupted. + offset += size; + return Some(old_segment); + } + // Either the current segment is None (meaning we didn't start + // drawing a segment yet since the last yielded one), so we keep + // searching for a track where we should draw one; or the current + // segment is Some but wasn't interrupted at this track, so we keep + // looping through the following tracks until it is interrupted, + // or we reach the end. + offset += size; + } + + // Reached the end of all tracks, so we interrupt and finish + // the current segment. Note that, on future calls to this + // closure, the current segment will necessarily be 'None', + // so the iterator will necessarily end (that is, we will return None) + // after this. + current_segment.take() + }) +} + +/// Returns the correct stroke with which to draw a vline right before column +/// 'x' when going through row 'y', given the stroke of the user-specified line +/// at this position, if any. Also returns the stroke's drawing priority, which +/// depends on its source. +/// +/// If the vline would go through a colspan, returns None (shouldn't be drawn). +/// If the one (when at the border) or two (otherwise) cells to the left and +/// right of the vline have right and left stroke overrides, respectively, +/// then the cells' stroke overrides are folded together with the vline's +/// stroke (with priority to the vline's stroke, followed by the right cell's +/// stroke, and, finally, the left cell's) and returned. If only one of the two +/// cells around the vline (if there are two) has an override, that cell's +/// stroke is given priority when folding. If, however, the cells around the +/// vline at this row do not have any stroke overrides, then the vline's own +/// stroke, as defined by user-specified lines (if any), is returned. +/// +/// The priority associated with the returned stroke follows the rules +/// described in the docs for 'generate_line_segment'. +pub(super) fn vline_stroke_at_row( + grid: &CellGrid, + x: usize, + y: usize, + stroke: Option>>, +) -> Option<(Arc>, StrokePriority)> { + 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; + } + } + + let (left_cell_stroke, left_cell_prioritized) = x + .checked_sub(1) + .and_then(|left_x| grid.parent_cell(left_x, y)) + .map(|left_cell| { + (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| { + (right_cell.stroke.left.clone(), right_cell.stroke_overridden.left) + }) + .unwrap_or((None, false)) + } else { + (None, false) + }; + + let priority = if stroke.is_some() { + StrokePriority::ExplicitLine + } else if left_cell_prioritized || right_cell_prioritized { + StrokePriority::CellStroke + } else { + StrokePriority::GridStroke + }; + + let (prioritized_cell_stroke, deprioritized_cell_stroke) = + if left_cell_prioritized && !right_cell_prioritized { + (left_cell_stroke, right_cell_stroke) + } else { + // When both cells' strokes have the same priority, we default to + // prioritizing the right cell's left stroke. + (right_cell_stroke, left_cell_stroke) + }; + + // When both cells specify a stroke for this line segment, fold + // both strokes, with priority to either the one prioritized cell, + // or to the right cell's left stroke in case of a tie. But when one of + // them doesn't specify a stroke, the other cell's stroke should be used + // instead, regardless of priority (hence the usage of 'fold_or'). + let cell_stroke = prioritized_cell_stroke.fold_or(deprioritized_cell_stroke); + + // Fold the line stroke and folded cell strokes, if possible. + // Give priority to the explicit line stroke. + // Otherwise, use whichever of the two isn't 'none' or unspecified. + let final_stroke = stroke.fold_or(cell_stroke); + + final_stroke.zip(Some(priority)) +} + +/// Returns the correct stroke with which to draw a hline on top of row 'y' +/// when going through column 'x', given the stroke of the user-specified line +/// at this position, if any. Also returns the stroke's drawing priority, which +/// depends on its source. +/// +/// 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 +/// priority to hline's stroke, followed by the bottom cell's stroke, and, +/// finally, the top cell's) and returned. If only one of the two cells around +/// the vline (if there are two) has an override, that cell's stroke is given +/// priority when folding. If, however, the cells around the hline at this +/// column do not have any stroke overrides, then the hline's own stroke, as +/// defined by user-specified lines (if any), is directly returned. +/// +/// The priority associated with the returned stroke follows the rules +/// described in the docs for 'generate_line_segment'. +pub(super) fn hline_stroke_at_column( + grid: &CellGrid, + y: usize, + x: usize, + stroke: Option>>, +) -> Option<(Arc>, 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 + }; + + let (top_cell_stroke, top_cell_prioritized) = y + .checked_sub(1) + .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 + }) + .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) + }) + .unwrap_or((None, false)); + + let (bottom_cell_stroke, bottom_cell_prioritized) = if 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) + }) + .unwrap_or((None, false)) + } else { + // No cell below the bottom border. + (None, false) + }; + + let priority = if stroke.is_some() { + StrokePriority::ExplicitLine + } else if top_cell_prioritized || bottom_cell_prioritized { + StrokePriority::CellStroke + } else { + StrokePriority::GridStroke + }; + + let (prioritized_cell_stroke, deprioritized_cell_stroke) = + if top_cell_prioritized && !bottom_cell_prioritized { + (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. + (bottom_cell_stroke, top_cell_stroke) + }; + + // When both cells specify a stroke for this line segment, fold + // both strokes, with priority to either the one prioritized cell, + // or to the bottom cell's top stroke in case of a tie. But when one of + // them doesn't specify a stroke, the other cell's stroke should be used + // instead, regardless of priority (hence the usage of 'fold_or'). + let cell_stroke = prioritized_cell_stroke.fold_or(deprioritized_cell_stroke); + + // Fold the line stroke and folded cell strokes, if possible. + // Give priority to the explicit line stroke. + // Otherwise, use whichever of the two isn't 'none' or unspecified. + let final_stroke = stroke.fold_or(cell_stroke); + + final_stroke.zip(Some(priority)) +} + +#[cfg(test)] +mod test { + use super::super::layout::{Entry, RowPiece}; + use super::*; + use crate::foundations::Content; + use crate::layout::{Cell, Sides, Sizing}; + use crate::util::NonZeroExt; + + fn sample_cell() -> Cell { + Cell { + body: Content::default(), + fill: None, + colspan: NonZeroUsize::ONE, + stroke: Sides::splat(Some(Arc::new(Stroke::default()))), + stroke_overridden: Sides::splat(false), + } + } + + fn cell_with_colspan(colspan: usize) -> Cell { + Cell { + body: Content::default(), + fill: None, + colspan: NonZeroUsize::try_from(colspan).unwrap(), + stroke: Sides::splat(Some(Arc::new(Stroke::default()))), + stroke_overridden: Sides::splat(false), + } + } + + fn sample_grid(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::Merged { parent: 2 }, + // row 1 + Entry::Cell(sample_cell()), + Entry::Cell(cell_with_colspan(3)), + 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::Merged { parent: 10 }, + // row 3 + Entry::Cell(sample_cell()), + Entry::Cell(cell_with_colspan(3)), + Entry::Merged { parent: 13 }, + Entry::Merged { parent: 13 }, + // row 4 + Entry::Cell(sample_cell()), + Entry::Merged { parent: 13 }, + Entry::Merged { parent: 13 }, + Entry::Merged { parent: 13 }, + // row 5 + Entry::Cell(sample_cell()), + Entry::Cell(sample_cell()), + Entry::Cell(cell_with_colspan(2)), + Entry::Merged { parent: 22 }, + ]; + 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_vline_splitting_without_gutter() { + let stroke = Arc::new(Stroke::default()); + let grid = sample_grid(false); + let rows = &[ + RowPiece { height: Abs::pt(1.0), y: 0 }, + RowPiece { height: Abs::pt(2.0), y: 1 }, + RowPiece { height: Abs::pt(4.0), y: 2 }, + RowPiece { height: Abs::pt(8.0), y: 3 }, + RowPiece { height: Abs::pt(16.0), y: 4 }, + RowPiece { height: Abs::pt(32.0), y: 5 }, + ]; + let expected_vline_splits = &[ + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.), + priority: StrokePriority::GridStroke, + }], + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.), + priority: StrokePriority::GridStroke, + }], + // interrupted a few times by colspans + 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.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16.), + length: Abs::pt(32.), + priority: StrokePriority::GridStroke, + }, + ], + // interrupted every time by colspans + vec![], + vec![LineSegment { + stroke, + offset: Abs::pt(0.), + length: Abs::pt(1. + 2. + 4. + 8. + 16. + 32.), + priority: StrokePriority::GridStroke, + }], + ]; + for (x, expected_splits) in expected_vline_splits.iter().enumerate() { + let tracks = rows.iter().map(|row| (row.y, row.height)); + assert_eq!( + expected_splits, + &generate_line_segments( + &grid, + tracks, + x, + &[], + x == grid.cols.len(), + vline_stroke_at_row + ) + .collect::>(), + ); + } + } + + #[test] + fn test_vline_splitting_with_gutter_and_per_cell_stroke() { + let stroke = Arc::new(Stroke::default()); + let grid = sample_grid(true); + let rows = &[ + RowPiece { height: Abs::pt(1.0), y: 0 }, + RowPiece { height: Abs::pt(2.0), y: 1 }, + RowPiece { height: Abs::pt(4.0), y: 2 }, + RowPiece { height: Abs::pt(8.0), y: 3 }, + RowPiece { height: Abs::pt(16.0), y: 4 }, + RowPiece { height: Abs::pt(32.0), y: 5 }, + RowPiece { height: Abs::pt(64.0), y: 6 }, + RowPiece { height: Abs::pt(128.0), y: 7 }, + RowPiece { height: Abs::pt(256.0), y: 8 }, + RowPiece { height: Abs::pt(512.0), y: 9 }, + RowPiece { height: Abs::pt(1024.0), y: 10 }, + ]; + // Stroke is per-cell so we skip gutter + let expected_vline_splits = &[ + // left border + 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.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8.), + length: Abs::pt(16.), + priority: StrokePriority::GridStroke, + }, + 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.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt( + 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512., + ), + length: Abs::pt(1024.), + priority: StrokePriority::GridStroke, + }, + ], + // gutter line below + 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.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8.), + length: Abs::pt(16.), + priority: StrokePriority::GridStroke, + }, + 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.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt( + 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512., + ), + length: Abs::pt(1024.), + priority: StrokePriority::GridStroke, + }, + ], + 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.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8.), + length: Abs::pt(16.), + priority: StrokePriority::GridStroke, + }, + 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.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt( + 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512., + ), + length: Abs::pt(1024.), + priority: StrokePriority::GridStroke, + }, + ], + // gutter line below + // the two lines below are interrupted multiple times by colspans + 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. + 4. + 8.), + length: Abs::pt(16.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt( + 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512., + ), + length: Abs::pt(1024.), + priority: StrokePriority::GridStroke, + }, + ], + 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. + 4. + 8.), + length: Abs::pt(16.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt( + 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512., + ), + length: Abs::pt(1024.), + priority: StrokePriority::GridStroke, + }, + ], + // gutter line below + // the two lines below can only cross certain gutter rows, because + // all non-gutter cells in the following column are merged with + // cells from the previous column. + vec![], + vec![], + // right border + 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.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8.), + length: Abs::pt(16.), + priority: StrokePriority::GridStroke, + }, + 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.), + priority: StrokePriority::GridStroke, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt( + 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512., + ), + length: Abs::pt(1024.), + priority: StrokePriority::GridStroke, + }, + ], + ]; + for (x, expected_splits) in expected_vline_splits.iter().enumerate() { + let tracks = rows.iter().map(|row| (row.y, row.height)); + assert_eq!( + expected_splits, + &generate_line_segments( + &grid, + tracks, + x, + &[], + x == grid.cols.len(), + vline_stroke_at_row + ) + .collect::>(), + ); + } + } + + #[test] + fn test_vline_splitting_with_gutter_and_explicit_vlines() { + let stroke = Arc::new(Stroke::default()); + let grid = sample_grid(true); + let rows = &[ + RowPiece { height: Abs::pt(1.0), y: 0 }, + RowPiece { height: Abs::pt(2.0), y: 1 }, + RowPiece { height: Abs::pt(4.0), y: 2 }, + RowPiece { height: Abs::pt(8.0), y: 3 }, + RowPiece { height: Abs::pt(16.0), y: 4 }, + RowPiece { height: Abs::pt(32.0), y: 5 }, + RowPiece { height: Abs::pt(64.0), y: 6 }, + RowPiece { height: Abs::pt(128.0), y: 7 }, + RowPiece { height: Abs::pt(256.0), y: 8 }, + RowPiece { height: Abs::pt(512.0), y: 9 }, + RowPiece { height: Abs::pt(1024.0), y: 10 }, + ]; + let expected_vline_splits = &[ + // left border + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt( + 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024., + ), + 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. + 128. + 256. + 512. + 1024., + ), + priority: StrokePriority::ExplicitLine, + }], + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt( + 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024., + ), + priority: StrokePriority::ExplicitLine, + }], + // gutter line below + // the two lines below are interrupted multiple times by colspans + 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.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.), + length: Abs::pt(512. + 1024.), + priority: StrokePriority::ExplicitLine, + }, + ], + 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.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.), + length: Abs::pt(512. + 1024.), + priority: StrokePriority::ExplicitLine, + }, + ], + // gutter line below + // the two lines below can only cross certain gutter rows, because + // all non-gutter cells in the following column are merged with + // cells from the previous column. + vec![ + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1.), + length: Abs::pt(2.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4.), + length: Abs::pt(8.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16.), + length: Abs::pt(32.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.), + length: Abs::pt(512.), + priority: StrokePriority::ExplicitLine, + }, + ], + vec![ + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1.), + length: Abs::pt(2.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4.), + length: Abs::pt(8.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16.), + length: Abs::pt(32.), + priority: StrokePriority::ExplicitLine, + }, + LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256.), + length: Abs::pt(512.), + priority: StrokePriority::ExplicitLine, + }, + ], + // right border + vec![LineSegment { + stroke: stroke.clone(), + offset: Abs::pt(0.), + length: Abs::pt( + 1. + 2. + 4. + 8. + 16. + 32. + 64. + 128. + 256. + 512. + 1024., + ), + priority: StrokePriority::ExplicitLine, + }], + ]; + for (x, expected_splits) in expected_vline_splits.iter().enumerate() { + let tracks = rows.iter().map(|row| (row.y, row.height)); + assert_eq!( + expected_splits, + &generate_line_segments( + &grid, + tracks, + x, + &[ + Line { + index: x, + start: 0, + end: None, + stroke: Some(stroke.clone()), + position: LinePosition::Before + }, + Line { + index: x, + start: 0, + end: None, + stroke: Some(stroke.clone()), + position: LinePosition::After + }, + ], + x == grid.cols.len(), + vline_stroke_at_row + ) + .collect::>(), + ); + } + } +} diff --git a/crates/typst/src/layout/grid/mod.rs b/crates/typst/src/layout/grid/mod.rs index ee2eeecfd..0ddecd841 100644 --- a/crates/typst/src/layout/grid/mod.rs +++ b/crates/typst/src/layout/grid/mod.rs @@ -1,8 +1,11 @@ mod layout; +mod lines; -pub use self::layout::{Cell, CellGrid, Celled, GridLayouter, ResolvableCell}; +pub use self::layout::{Cell, CellGrid, Celled, GridItem, GridLayouter, ResolvableCell}; +pub use self::lines::LinePosition; use std::num::NonZeroUsize; +use std::sync::Arc; use ecow::eco_format; use smallvec::{smallvec, SmallVec}; @@ -13,10 +16,11 @@ use crate::foundations::{ cast, elem, scope, Array, Content, Fold, Packed, Show, Smart, StyleChain, Value, }; use crate::layout::{ - AlignElem, Alignment, Axes, Fragment, LayoutMultiple, Length, Regions, Rel, Sides, - Sizing, + Abs, AlignElem, Alignment, Axes, Dir, Fragment, LayoutMultiple, Length, + OuterHAlignment, OuterVAlignment, Regions, Rel, Sides, Sizing, }; use crate::syntax::Span; +use crate::text::TextElem; use crate::util::NonZeroExt; use crate::visualize::{Paint, Stroke}; @@ -235,12 +239,14 @@ pub struct GridElem { /// Grids have no strokes by default, which can be changed by setting this /// option to the desired stroke. /// - /// _Note:_ Richer stroke customization for individual cells is not yet - /// implemented, but will be in the future. In the meantime, you can use the - /// third-party [tablex library](https://github.com/PgBiel/typst-tablex/). + /// If it is necessary to place lines which can cross spacing between cells + /// produced by the `gutter` option, or to override the stroke between + /// multiple specific cells, consider specifying one or more of + /// [`grid.hline`]($grid.hline) and [`grid.vline`]($grid.vline) alongside + /// your grid cells. #[resolve] #[fold] - pub stroke: Option, + pub stroke: Celled>>>>, /// How much to pad the cells' content. /// @@ -266,17 +272,25 @@ pub struct GridElem { #[fold] pub inset: Sides>>, - /// The contents of the grid cells. + /// The contents of the grid cells, plus any extra grid lines specified + /// with the [`grid.hline`]($grid.hline) and [`grid.vline`]($grid.vline) + /// elements. /// /// The cells are populated in row-major order. #[variadic] - pub children: Vec>, + pub children: Vec, } #[scope] impl GridElem { #[elem] type GridCell; + + #[elem] + type GridHLine; + + #[elem] + type GridVLine; } impl LayoutMultiple for Packed { @@ -294,26 +308,60 @@ impl LayoutMultiple for Packed { let column_gutter = self.column_gutter(styles); let row_gutter = self.row_gutter(styles); let fill = self.fill(styles); - let stroke = self.stroke(styles).map(Stroke::unwrap_or_default); + let stroke = self.stroke(styles); let tracks = Axes::new(columns.0.as_slice(), rows.0.as_slice()); let gutter = Axes::new(column_gutter.0.as_slice(), row_gutter.0.as_slice()); // Use trace to link back to the grid when a specific cell errors let tracepoint = || Tracepoint::Call(Some(eco_format!("grid"))); + let items = self.children().iter().map(|child| match child { + GridChild::HLine(hline) => GridItem::HLine { + y: hline.y(styles), + start: hline.start(styles), + end: hline.end(styles), + stroke: hline.stroke(styles), + span: hline.span(), + position: match hline.position(styles) { + OuterVAlignment::Top => LinePosition::Before, + OuterVAlignment::Bottom => LinePosition::After, + }, + }, + GridChild::VLine(vline) => GridItem::VLine { + x: vline.x(styles), + start: vline.start(styles), + end: vline.end(styles), + stroke: vline.stroke(styles), + span: vline.span(), + position: match vline.position(styles) { + OuterHAlignment::Left if TextElem::dir_in(styles) == Dir::RTL => { + LinePosition::After + } + OuterHAlignment::Right if TextElem::dir_in(styles) == Dir::RTL => { + LinePosition::Before + } + OuterHAlignment::Start | OuterHAlignment::Left => { + LinePosition::Before + } + OuterHAlignment::End | OuterHAlignment::Right => LinePosition::After, + }, + }, + GridChild::Cell(cell) => GridItem::Cell(cell.clone()), + }); let grid = CellGrid::resolve( tracks, gutter, - self.children(), + items, fill, align, inset, + &stroke, engine, styles, self.span(), ) .trace(engine.world, tracepoint, self.span())?; - let layouter = GridLayouter::new(&grid, &stroke, regions, styles, self.span()); + let layouter = GridLayouter::new(&grid, regions, styles, self.span()); // Measure the columns and layout the grid row-by-row. layouter.layout(engine) @@ -332,6 +380,151 @@ cast! { values: Array => Self(values.into_iter().map(Value::cast).collect::>()?), } +/// Any child of a grid element. +#[derive(Debug, PartialEq, Clone, Hash)] +pub enum GridChild { + HLine(Packed), + VLine(Packed), + Cell(Packed), +} + +cast! { + GridChild, + self => match self { + Self::HLine(hline) => hline.into_value(), + Self::VLine(vline) => vline.into_value(), + Self::Cell(cell) => cell.into_value(), + }, + v: Content => v.into(), +} + +impl From for GridChild { + fn from(value: Content) -> Self { + value + .into_packed::() + .map(GridChild::HLine) + .or_else(|value| value.into_packed::().map(GridChild::VLine)) + .or_else(|value| value.into_packed::().map(GridChild::Cell)) + .unwrap_or_else(|value| { + let span = value.span(); + GridChild::Cell(Packed::new(GridCell::new(value)).spanned(span)) + }) + } +} + +/// A horizontal line in the grid. +/// +/// Overrides any per-cell stroke, including stroke specified through the +/// grid's `stroke` field. Can cross spacing between cells created through +/// the grid's `column-gutter` option. +#[elem(name = "hline", title = "Grid Horizontal Line")] +pub struct GridHLine { + /// The row above which the horizontal line is placed (zero-indexed). + /// If the `position` field is set to `{bottom}`, the line is placed below + /// the row with the given index instead (see that field's docs for + /// details). + /// + /// Specifying `{auto}` causes the line to be placed at the row below the + /// last automatically positioned cell (that is, cell without coordinate + /// overrides) before the line among the grid's children. If there is no + /// such cell before the line, it is placed at the top of the grid (row 0). + /// Note that specifying for this option exactly the total amount of rows + /// in the grid causes this horizontal line to override the bottom border + /// of the grid, while a value of 0 overrides the top border. + pub y: Smart, + + /// The column at which the horizontal line starts (zero-indexed, inclusive). + pub start: usize, + + /// The column before which the horizontal line ends (zero-indexed, + /// exclusive). + /// Therefore, the horizontal line will be drawn up to and across column + /// `end - 1`. + /// + /// A value equal to `{none}` or to the amount of columns causes it to + /// extend all the way towards the end of the grid. + pub end: Option, + + /// The line's stroke. + /// + /// Specifying `{none}` interrupts previous hlines placed across this + /// line's range, but does not affect per-cell stroke or vlines. + #[resolve] + #[fold] + #[default(Some(Arc::new(Stroke::default())))] + pub stroke: Option>, + + /// The position at which the line is placed, given its row (`y`) - either + /// `{top}` to draw above it or `{bottom}` to draw below it. + /// + /// This setting is only relevant when row gutter is enabled (and + /// shouldn't be used otherwise - prefer just increasing the `y` field by + /// one instead), since then the position below a row becomes different + /// from the position above the next row due to the spacing between both. + #[default(OuterVAlignment::Top)] + pub position: OuterVAlignment, +} + +/// A vertical line in the grid. +/// +/// Overrides any per-cell stroke, including stroke specified through the +/// grid's `stroke` field. Can cross spacing between cells created through +/// the grid's `row-gutter` option. +#[elem(name = "vline", title = "Grid Vertical Line")] +pub struct GridVLine { + /// The column before which the horizontal line is placed (zero-indexed). + /// If the `position` field is set to `{end}`, the line is placed after the + /// column with the given index instead (see that field's docs for + /// details). + /// + /// Specifying `{auto}` causes the line to be placed at the column after + /// the last automatically positioned cell (that is, cell without + /// coordinate overrides) before the line among the grid's children. If + /// there is no such cell before the line, it is placed before the grid's + /// first column (column 0). + /// Note that specifying for this option exactly the total amount of + /// columns in the grid causes this vertical line to override the end + /// border of the grid (right in LTR, left in RTL), while a value of 0 + /// overrides the start border (left in LTR, right in RTL). + pub x: Smart, + + /// The row at which the vertical line starts (zero-indexed, inclusive). + pub start: usize, + + /// The row on top of which the vertical line ends (zero-indexed, + /// exclusive). + /// Therefore, the vertical line will be drawn up to and across row + /// `end - 1`. + /// + /// A value equal to `{none}` or to the amount of rows causes it to extend + /// all the way towards the bottom of the grid. + pub end: Option, + + /// The line's stroke. + /// + /// Specifying `{none}` interrupts previous vlines placed across this + /// line's range, but does not affect per-cell stroke or hlines. + #[resolve] + #[fold] + #[default(Some(Arc::new(Stroke::default())))] + pub stroke: Option>, + + /// The position at which the line is placed, given its column (`x`) - + /// either `{start}` to draw before it or `{end}` to draw after it. + /// + /// The values `{left}` and `{right}` are also accepted, but discouraged as + /// they cause your grid to be inconsistent between left-to-right and + /// right-to-left documents. + /// + /// This setting is only relevant when column gutter is enabled (and + /// shouldn't be used otherwise - prefer just increasing the `x` field by + /// one instead), since then the position after a column becomes different + /// from the position before the next column due to the spacing between + /// both. + #[default(OuterHAlignment::Start)] + pub position: OuterHAlignment, +} + /// A cell in the grid. Use this to either override grid properties for a /// particular cell, or in show rules to apply certain styles to multiple cells /// at once. @@ -383,7 +576,7 @@ cast! { pub struct GridCell { /// The cell's body. #[required] - body: Content, + pub body: Content, /// The cell's column (zero-indexed). /// This field may be used in show rules to style a cell depending on its @@ -408,7 +601,7 @@ pub struct GridCell { /// [1], grid.cell(x: 3)[4], [2], /// ) /// ``` - x: Smart, + pub x: Smart, /// The cell's row (zero-indexed). /// This field may be used in show rules to style a cell depending on its @@ -427,20 +620,25 @@ pub struct GridCell { /// [A], grid.cell(y: 1)[B], grid.cell(y: 1)[C], grid.cell(y: 2)[D] /// ) /// ``` - y: Smart, + pub y: Smart, /// The amount of columns spanned by this cell. #[default(NonZeroUsize::ONE)] - colspan: NonZeroUsize, + pub colspan: NonZeroUsize, /// The cell's fill override. - fill: Smart>, + pub fill: Smart>, /// The cell's alignment override. - align: Smart, + pub align: Smart, /// The cell's inset override. - inset: Smart>>>, + pub inset: Smart>>>, + + /// The cell's stroke override. + #[resolve] + #[fold] + pub stroke: Sides>>>, } cast! { @@ -462,11 +660,27 @@ impl ResolvableCell for Packed { fill: &Option, align: Smart, inset: Sides>>, + stroke: Sides>>>>, styles: StyleChain, ) -> Cell { let cell = &mut *self; let colspan = cell.colspan(styles); let fill = cell.fill(styles).unwrap_or_else(|| fill.clone()); + + let cell_stroke = cell.stroke(styles); + let stroke_overridden = + cell_stroke.as_ref().map(|side| matches!(side, Some(Some(_)))); + + // Using a typical 'Sides' fold, an unspecified side loses to a + // specified side. Additionally, when both are specified, an inner + // None wins over the outer Some, and vice-versa. When both are + // specified and Some, fold occurs, which, remarkably, leads to an Arc + // clone. + // + // In the end, we flatten because, for layout purposes, an unspecified + // cell stroke is the same as specifying 'none', so we equate the two + // concepts. + let stroke = cell_stroke.fold(stroke).map(Option::flatten); cell.push_x(Smart::Custom(x)); cell.push_y(Smart::Custom(y)); cell.push_fill(Smart::Custom(fill.clone())); @@ -482,7 +696,26 @@ impl ResolvableCell for Packed { cell.push_inset(Smart::Custom( cell.inset(styles).map_or(inset, |inner| inner.fold(inset)), )); - Cell { body: self.pack(), fill, colspan } + cell.push_stroke( + // Here we convert the resolved stroke to a regular stroke, however + // with resolved units (that is, 'em' converted to absolute units). + // We also convert any stroke unspecified by both the cell and the + // outer stroke ('None' in the folded stroke) to 'none', that is, + // all sides are present in the resulting Sides object accessible + // by show rules on grid cells. + stroke.as_ref().map(|side| { + Some(side.as_ref().map(|cell_stroke| { + Arc::new((**cell_stroke).clone().map(Length::from)) + })) + }), + ); + Cell { + body: self.pack(), + fill, + colspan, + stroke, + stroke_overridden, + } } fn x(&self, styles: StyleChain) -> Smart { diff --git a/crates/typst/src/layout/sides.rs b/crates/typst/src/layout/sides.rs index bb35ffb3e..119d7f194 100644 --- a/crates/typst/src/layout/sides.rs +++ b/crates/typst/src/layout/sides.rs @@ -3,7 +3,8 @@ use std::ops::Add; use crate::diag::{bail, StrResult}; use crate::foundations::{ - cast, CastInfo, Dict, Fold, FromValue, IntoValue, Reflect, Resolve, StyleChain, Value, + cast, AlternativeFold, CastInfo, Dict, Fold, FromValue, IntoValue, Reflect, Resolve, + StyleChain, Value, }; use crate::layout::{Abs, Alignment, Axes, Axis, Corner, Rel, Size}; use crate::util::Get; @@ -245,13 +246,10 @@ impl Resolve for Sides { impl Fold for Sides> { fn fold(self, outer: Self) -> Self { - self.zip(outer).map(|(inner, outer)| match (inner, outer) { - (Some(inner), Some(outer)) => Some(inner.fold(outer)), - // Usually, folding an inner `None` with an `outer` preferres the - // explicit `None`. However, here `None` means unspecified and thus - // we want `outer`. - (inner, outer) => inner.or(outer), - }) + // Usually, folding an inner `None` with an `outer` preferres the + // explicit `None`. However, here `None` means unspecified and thus + // we want `outer`, so we use `fold_or` to opt into such behavior. + self.zip(outer).map(|(inner, outer)| inner.fold_or(outer)) } } diff --git a/crates/typst/src/model/bibliography.rs b/crates/typst/src/model/bibliography.rs index 84ab2fffb..ca7695d93 100644 --- a/crates/typst/src/model/bibliography.rs +++ b/crates/typst/src/model/bibliography.rs @@ -29,7 +29,8 @@ use crate::foundations::{ }; use crate::introspection::{Introspector, Locatable, Location}; use crate::layout::{ - BlockElem, Em, GridCell, GridElem, HElem, PadElem, Sizing, TrackSizings, VElem, + BlockElem, Em, GridCell, GridChild, GridElem, HElem, PadElem, Sizing, TrackSizings, + VElem, }; use crate::model::{ CitationForm, CiteGroup, Destination, FootnoteElem, HeadingElem, LinkElem, ParElem, @@ -237,11 +238,13 @@ impl Show for Packed { if references.iter().any(|(prefix, _)| prefix.is_some()) { let mut cells = vec![]; for (prefix, reference) in references { - cells.push( + cells.push(GridChild::Cell( Packed::new(GridCell::new(prefix.clone().unwrap_or_default())) .spanned(span), - ); - cells.push(Packed::new(GridCell::new(reference.clone())).spanned(span)); + )); + cells.push(GridChild::Cell( + Packed::new(GridCell::new(reference.clone())).spanned(span), + )); } seq.push(VElem::new(row_gutter).with_weakness(3).pack()); @@ -945,8 +948,8 @@ impl ElemRenderer<'_> { if let Some(prefix) = suf_prefix { const COLUMN_GUTTER: Em = Em::new(0.65); content = GridElem::new(vec![ - Packed::new(GridCell::new(prefix)).spanned(self.span), - Packed::new(GridCell::new(content)).spanned(self.span), + GridChild::Cell(Packed::new(GridCell::new(prefix)).spanned(self.span)), + GridChild::Cell(Packed::new(GridCell::new(content)).spanned(self.span)), ]) .with_columns(TrackSizings(smallvec![Sizing::Auto; 2])) .with_column_gutter(TrackSizings(smallvec![COLUMN_GUTTER.into()])) diff --git a/crates/typst/src/model/enum.rs b/crates/typst/src/model/enum.rs index bb009df7c..6677913bd 100644 --- a/crates/typst/src/model/enum.rs +++ b/crates/typst/src/model/enum.rs @@ -271,7 +271,6 @@ impl LayoutMultiple for Packed { number = number.saturating_add(1); } - let stroke = None; let grid = CellGrid::new( Axes::with_x(&[ Sizing::Rel(indent.into()), @@ -282,7 +281,7 @@ impl LayoutMultiple for Packed { Axes::with_y(&[gutter.into()]), cells, ); - let layouter = GridLayouter::new(&grid, &stroke, regions, styles, self.span()); + let layouter = GridLayouter::new(&grid, regions, styles, self.span()); layouter.layout(engine) } diff --git a/crates/typst/src/model/list.rs b/crates/typst/src/model/list.rs index 6de78bbd5..12a698d1f 100644 --- a/crates/typst/src/model/list.rs +++ b/crates/typst/src/model/list.rs @@ -168,7 +168,6 @@ impl LayoutMultiple for Packed { )); } - let stroke = None; let grid = CellGrid::new( Axes::with_x(&[ Sizing::Rel(indent.into()), @@ -179,7 +178,7 @@ impl LayoutMultiple for Packed { Axes::with_y(&[gutter.into()]), cells, ); - let layouter = GridLayouter::new(&grid, &stroke, regions, styles, self.span()); + let layouter = GridLayouter::new(&grid, regions, styles, self.span()); layouter.layout(engine) } diff --git a/crates/typst/src/model/table.rs b/crates/typst/src/model/table.rs index 256459c74..ae4dbce76 100644 --- a/crates/typst/src/model/table.rs +++ b/crates/typst/src/model/table.rs @@ -1,4 +1,5 @@ use std::num::NonZeroUsize; +use std::sync::Arc; use ecow::eco_format; @@ -8,12 +9,13 @@ use crate::foundations::{ cast, elem, scope, Content, Fold, Packed, Show, Smart, StyleChain, }; use crate::layout::{ - show_grid_cell, Abs, Alignment, Axes, Cell, CellGrid, Celled, Fragment, GridLayouter, - LayoutMultiple, Length, Regions, Rel, ResolvableCell, Sides, TrackSizings, + show_grid_cell, Abs, Alignment, Axes, Cell, CellGrid, Celled, Dir, Fragment, + GridItem, GridLayouter, LayoutMultiple, Length, LinePosition, OuterHAlignment, + OuterVAlignment, Regions, Rel, ResolvableCell, Sides, TrackSizings, }; use crate::model::Figurable; use crate::syntax::Span; -use crate::text::{Lang, LocalName, Region}; +use crate::text::{Lang, LocalName, Region, TextElem}; use crate::util::NonZeroExt; use crate::visualize::{Paint, Stroke}; @@ -166,13 +168,17 @@ pub struct TableElem { /// /// Strokes can be disabled by setting this to `{none}`. /// - /// _Note:_ Richer stroke customization for individual cells is not yet - /// implemented, but will be in the future. In the meantime, you can use the - /// third-party [tablex library](https://github.com/PgBiel/typst-tablex/). + /// If it is necessary to place lines which can cross spacing between cells + /// produced by the `gutter` option, or to override the stroke between + /// multiple specific cells, consider specifying one or more of + /// [`table.hline`]($table.hline) and [`table.vline`]($table.vline) alongside + /// your table cells. + /// + /// See the [grid documentation]($grid) for more information on stroke. #[resolve] #[fold] - #[default(Some(Stroke::default()))] - pub stroke: Option, + #[default(Celled::Value(Sides::splat(Some(Some(Arc::new(Stroke::default()))))))] + pub stroke: Celled>>>>, /// How much to pad the cells' content. /// @@ -197,15 +203,23 @@ pub struct TableElem { #[default(Sides::splat(Some(Abs::pt(5.0).into())))] pub inset: Sides>>, - /// The contents of the table cells. + /// The contents of the table cells, plus any extra table lines specified + /// with the [`table.hline`]($table.hline) and + /// [`table.vline`]($table.vline) elements. #[variadic] - pub children: Vec>, + pub children: Vec, } #[scope] impl TableElem { #[elem] type TableCell; + + #[elem] + type TableHLine; + + #[elem] + type TableVLine; } impl LayoutMultiple for Packed { @@ -223,26 +237,60 @@ impl LayoutMultiple for Packed { let column_gutter = self.column_gutter(styles); let row_gutter = self.row_gutter(styles); let fill = self.fill(styles); - let stroke = self.stroke(styles).map(Stroke::unwrap_or_default); + let stroke = self.stroke(styles); let tracks = Axes::new(columns.0.as_slice(), rows.0.as_slice()); let gutter = Axes::new(column_gutter.0.as_slice(), row_gutter.0.as_slice()); // Use trace to link back to the table when a specific cell errors let tracepoint = || Tracepoint::Call(Some(eco_format!("table"))); + let items = self.children().iter().map(|child| match child { + TableChild::HLine(hline) => GridItem::HLine { + y: hline.y(styles), + start: hline.start(styles), + end: hline.end(styles), + stroke: hline.stroke(styles), + span: hline.span(), + position: match hline.position(styles) { + OuterVAlignment::Top => LinePosition::Before, + OuterVAlignment::Bottom => LinePosition::After, + }, + }, + TableChild::VLine(vline) => GridItem::VLine { + x: vline.x(styles), + start: vline.start(styles), + end: vline.end(styles), + stroke: vline.stroke(styles), + span: vline.span(), + position: match vline.position(styles) { + OuterHAlignment::Left if TextElem::dir_in(styles) == Dir::RTL => { + LinePosition::After + } + OuterHAlignment::Right if TextElem::dir_in(styles) == Dir::RTL => { + LinePosition::Before + } + OuterHAlignment::Start | OuterHAlignment::Left => { + LinePosition::Before + } + OuterHAlignment::End | OuterHAlignment::Right => LinePosition::After, + }, + }, + TableChild::Cell(cell) => GridItem::Cell(cell.clone()), + }); let grid = CellGrid::resolve( tracks, gutter, - self.children(), + items, fill, align, inset, + &stroke, engine, styles, self.span(), ) .trace(engine.world, tracepoint, self.span())?; - let layouter = GridLayouter::new(&grid, &stroke, regions, styles, self.span()); + let layouter = GridLayouter::new(&grid, regions, styles, self.span()); layouter.layout(engine) } } @@ -286,6 +334,122 @@ impl LocalName for Packed { impl Figurable for Packed {} +/// Any child of a table element. +#[derive(Debug, PartialEq, Clone, Hash)] +pub enum TableChild { + HLine(Packed), + VLine(Packed), + Cell(Packed), +} + +cast! { + TableChild, + self => match self { + Self::HLine(hline) => hline.into_value(), + Self::VLine(vline) => vline.into_value(), + Self::Cell(cell) => cell.into_value(), + }, + v: Content => v.into(), +} + +impl From for TableChild { + fn from(value: Content) -> Self { + value + .into_packed::() + .map(TableChild::HLine) + .or_else(|value| value.into_packed::().map(TableChild::VLine)) + .or_else(|value| value.into_packed::().map(TableChild::Cell)) + .unwrap_or_else(|value| { + let span = value.span(); + TableChild::Cell(Packed::new(TableCell::new(value)).spanned(span)) + }) + } +} + +/// A horizontal line in the table. See the docs for +/// [`grid.hline`]($grid.hline) for more information regarding how to use this +/// element's fields. +/// +/// Overrides any per-cell stroke, including stroke specified through the +/// table's `stroke` field. Can cross spacing between cells created through +/// the table's `column-gutter` option. +#[elem(name = "hline", title = "Table Horizontal Line")] +pub struct TableHLine { + /// The row above which the horizontal line is placed (zero-indexed). + /// Functions identically to the `y` field in [`grid.hline`]($grid.hline). + pub y: Smart, + + /// The column at which the horizontal line starts (zero-indexed, inclusive). + pub start: usize, + + /// The column before which the horizontal line ends (zero-indexed, + /// exclusive). + pub end: Option, + + /// The line's stroke. + /// + /// Specifying `{none}` interrupts previous hlines placed across this + /// line's range, but does not affect per-cell stroke or vlines. + #[resolve] + #[fold] + #[default(Some(Arc::new(Stroke::default())))] + pub stroke: Option>, + + /// The position at which the line is placed, given its row (`y`) - either + /// `{top}` to draw above it or `{bottom}` to draw below it. + /// + /// This setting is only relevant when row gutter is enabled (and + /// shouldn't be used otherwise - prefer just increasing the `y` field by + /// one instead), since then the position below a row becomes different + /// from the position above the next row due to the spacing between both. + #[default(OuterVAlignment::Top)] + pub position: OuterVAlignment, +} + +/// A vertical line in the table. See the docs for [`grid.vline`]($grid.vline) +/// for more information regarding how to use this element's fields. +/// +/// Overrides any per-cell stroke, including stroke specified through the +/// table's `stroke` field. Can cross spacing between cells created through +/// the table's `row-gutter` option. +#[elem(name = "vline", title = "Table Vertical Line")] +pub struct TableVLine { + /// The column before which the horizontal line is placed (zero-indexed). + /// Functions identically to the `x` field in [`grid.vline`]($grid.vline). + pub x: Smart, + + /// The row at which the vertical line starts (zero-indexed, inclusive). + pub start: usize, + + /// The row on top of which the vertical line ends (zero-indexed, + /// exclusive). + pub end: Option, + + /// The line's stroke. + /// + /// Specifying `{none}` interrupts previous vlines placed across this + /// line's range, but does not affect per-cell stroke or hlines. + #[resolve] + #[fold] + #[default(Some(Arc::new(Stroke::default())))] + pub stroke: Option>, + + /// The position at which the line is placed, given its column (`x`) - + /// either `{start}` to draw before it or `{end}` to draw after it. + /// + /// The values `{left}` and `{right}` are also accepted, but discouraged as + /// they cause your table to be inconsistent between left-to-right and + /// right-to-left documents. + /// + /// This setting is only relevant when column gutter is enabled (and + /// shouldn't be used otherwise - prefer just increasing the `x` field by + /// one instead), since then the position after a column becomes different + /// from the position before the next column due to the spacing between + /// both. + #[default(OuterHAlignment::Start)] + pub position: OuterHAlignment, +} + /// A cell in the table. Use this to either override table properties for a /// particular cell, or in show rules to apply certain styles to multiple cells /// at once. @@ -336,28 +500,33 @@ impl Figurable for Packed {} pub struct TableCell { /// The cell's body. #[required] - body: Content, + pub body: Content, /// The cell's column (zero-indexed). /// Functions identically to the `x` field in [`grid.cell`]($grid.cell). - x: Smart, + pub x: Smart, /// The cell's row (zero-indexed). /// Functions identically to the `y` field in [`grid.cell`]($grid.cell). - y: Smart, + pub y: Smart, /// The cell's fill override. - fill: Smart>, + pub fill: Smart>, /// The amount of columns spanned by this cell. #[default(NonZeroUsize::ONE)] - colspan: NonZeroUsize, + pub colspan: NonZeroUsize, /// The cell's alignment override. - align: Smart, + pub align: Smart, /// The cell's inset override. - inset: Smart>>>, + pub inset: Smart>>>, + + /// The cell's stroke override. + #[resolve] + #[fold] + pub stroke: Sides>>>, } cast! { @@ -379,11 +548,27 @@ impl ResolvableCell for Packed { fill: &Option, align: Smart, inset: Sides>>, + stroke: Sides>>>>, styles: StyleChain, ) -> Cell { let cell = &mut *self; let colspan = cell.colspan(styles); let fill = cell.fill(styles).unwrap_or_else(|| fill.clone()); + + let cell_stroke = cell.stroke(styles); + let stroke_overridden = + cell_stroke.as_ref().map(|side| matches!(side, Some(Some(_)))); + + // Using a typical 'Sides' fold, an unspecified side loses to a + // specified side. Additionally, when both are specified, an inner + // None wins over the outer Some, and vice-versa. When both are + // specified and Some, fold occurs, which, remarkably, leads to an Arc + // clone. + // + // In the end, we flatten because, for layout purposes, an unspecified + // cell stroke is the same as specifying 'none', so we equate the two + // concepts. + let stroke = cell_stroke.fold(stroke).map(Option::flatten); cell.push_x(Smart::Custom(x)); cell.push_y(Smart::Custom(y)); cell.push_fill(Smart::Custom(fill.clone())); @@ -399,7 +584,26 @@ impl ResolvableCell for Packed { cell.push_inset(Smart::Custom( cell.inset(styles).map_or(inset, |inner| inner.fold(inset)), )); - Cell { body: self.pack(), fill, colspan } + cell.push_stroke( + // Here we convert the resolved stroke to a regular stroke, however + // with resolved units (that is, 'em' converted to absolute units). + // We also convert any stroke unspecified by both the cell and the + // outer stroke ('None' in the folded stroke) to 'none', that is, + // all sides are present in the resulting Sides object accessible + // by show rules on table cells. + stroke.as_ref().map(|side| { + Some(side.as_ref().map(|cell_stroke| { + Arc::new((**cell_stroke).clone().map(Length::from)) + })) + }), + ); + Cell { + body: self.pack(), + fill, + colspan, + stroke, + stroke_overridden, + } } fn x(&self, styles: StyleChain) -> Smart { diff --git a/tests/ref/layout/grid-colspan.png b/tests/ref/layout/grid-colspan.png index 46577c62c..e16ca347e 100644 Binary files a/tests/ref/layout/grid-colspan.png and b/tests/ref/layout/grid-colspan.png differ diff --git a/tests/ref/layout/grid-positioning.png b/tests/ref/layout/grid-positioning.png index 5d60c8b71..cac93f403 100644 Binary files a/tests/ref/layout/grid-positioning.png and b/tests/ref/layout/grid-positioning.png differ diff --git a/tests/ref/layout/grid-rtl.png b/tests/ref/layout/grid-rtl.png index a1bfad567..f81e992e4 100644 Binary files a/tests/ref/layout/grid-rtl.png and b/tests/ref/layout/grid-rtl.png differ diff --git a/tests/ref/layout/grid-stroke.png b/tests/ref/layout/grid-stroke.png new file mode 100644 index 000000000..c31519e62 Binary files /dev/null and b/tests/ref/layout/grid-stroke.png differ diff --git a/tests/ref/layout/table-cell.png b/tests/ref/layout/table-cell.png index 8e91e6459..647a2e105 100644 Binary files a/tests/ref/layout/table-cell.png and b/tests/ref/layout/table-cell.png differ diff --git a/tests/ref/layout/table.png b/tests/ref/layout/table.png index b6b31eb16..f2a9d1049 100644 Binary files a/tests/ref/layout/table.png and b/tests/ref/layout/table.png differ diff --git a/tests/typ/layout/grid-rtl.typ b/tests/typ/layout/grid-rtl.typ index 33a688887..dcac9810c 100644 --- a/tests/typ/layout/grid-rtl.typ +++ b/tests/typ/layout/grid-rtl.typ @@ -88,3 +88,52 @@ [e], [g], grid.cell(colspan: 2)[eee\ e\ e\ e], grid.cell(colspan: 4)[eeee e e e] ) + +--- +// Test left and right for vlines in RTL +#set text(dir: rtl) +#grid( + columns: 3, + inset: 5pt, + grid.vline(stroke: red, position: left), grid.vline(stroke: green, position: right), [a], + grid.vline(stroke: red, position: left), grid.vline(stroke: 2pt, position: right), [b], + grid.vline(stroke: red, position: left), grid.vline(stroke: 2pt, position: right), [c], + grid.vline(stroke: aqua, position: right) +) + +#grid( + columns: 3, + inset: 5pt, + gutter: 3pt, + grid.vline(stroke: green, position: left), grid.vline(stroke: red, position: right), [a], + grid.vline(stroke: blue, position: left), grid.vline(stroke: red, position: right), [b], + grid.vline(stroke: blue, position: left), grid.vline(stroke: red, position: right), [c], + grid.vline(stroke: 2pt, position: right) +) + +#grid( + columns: 3, + inset: 5pt, + grid.vline(stroke: green, position: start), grid.vline(stroke: red, position: end), [a], + grid.vline(stroke: 2pt, position: start), grid.vline(stroke: red, position: end), [b], + grid.vline(stroke: 2pt, position: start), grid.vline(stroke: red, position: end), [c], + grid.vline(stroke: 2pt, position: start) +) + +#grid( + columns: 3, + inset: 5pt, + gutter: 3pt, + grid.vline(stroke: green, position: start), grid.vline(stroke: red, position: end), [a], + grid.vline(stroke: blue, position: start), grid.vline(stroke: red, position: end), [b], + grid.vline(stroke: blue, position: start), grid.vline(stroke: red, position: end), [c], + grid.vline(stroke: 2pt, position: start) +) + +--- +// Error: 3:8-3:34 cannot place vertical line at the 'end' position of the end border (x = 1) +// Hint: 3:8-3:34 set the line's position to 'start' or place it at a smaller 'x' index +#set text(dir: rtl) +#grid( + [a], grid.vline(position: left) +) diff --git a/tests/typ/layout/grid-stroke.typ b/tests/typ/layout/grid-stroke.typ new file mode 100644 index 000000000..87389ad56 --- /dev/null +++ b/tests/typ/layout/grid-stroke.typ @@ -0,0 +1,379 @@ +#let double-line = pattern(size: (1.5pt, 1.5pt), { + place(line(stroke: .6pt, start: (0%, 50%), end: (100%, 50%))) +}) + +#table( + stroke: (_, y) => if y != 1 { (bottom: black) }, + columns: 3, + table.cell(colspan: 3, align: center)[*Epic Table*], + align(center)[*Name*], align(center)[*Age*], align(center)[*Data*], + table.hline(stroke: (paint: double-line, thickness: 2pt)), + [John], [30], [None], + [Martha], [20], [A], + [Joseph], [35], [D] +) + +--- +// Test folding +#set grid(stroke: red) +#set grid(stroke: 5pt) + +#grid( + inset: 10pt, + columns: 2, + stroke: stroke(dash: "loosely-dotted"), + grid.vline(start: 2, end: 3, stroke: (paint: green, dash: none)), + [a], [b], + grid.hline(end: 1, stroke: blue), + [c], [d], + [e], grid.cell(stroke: aqua)[f] +) + +--- +// Test set rules on cells and folding +#set table.cell(stroke: 4pt) +#set table.cell(stroke: blue) +#set table.hline(stroke: red) +#set table.hline(stroke: 0.75pt) +#set table.vline(stroke: 0.75pt) +#set table.vline(stroke: aqua) + +#table( + columns: 3, + gutter: 3pt, + inset: 5pt, + [a], [b], table.vline(position: end), [c], + [d], [e], [f], + table.hline(position: bottom), + [g], [h], [i], +) + +--- +// Test stroke field on cell show rules +#set grid.cell(stroke: (x: 4pt)) +#set grid.cell(stroke: (x: blue)) +#show grid.cell: it => { + test(it.stroke, (left: stroke(paint: blue, thickness: 4pt, dash: "loosely-dotted"), right: blue + 4pt, top: stroke(thickness: 1pt), bottom: none)) + it +} +#grid( + stroke: (left: (dash: "loosely-dotted")), + inset: 5pt, + grid.hline(stroke: red), + grid.cell(stroke: (top: 1pt))[a], grid.vline(stroke: yellow), +) + +--- +#table( + columns: 3, + [a], table.cell(colspan: 2)[b c], + table.cell(stroke: blue)[d], [e], [f], + [g], [h], table.cell(stroke: (left: yellow, top: green, right: aqua, bottom: red))[i], + [j], [k], [l], + table.cell(stroke: 3pt)[m], [n], table.cell(stroke: (dash: "loosely-dotted"))[o], +) + +--- +// Test per-column stroke array +#let t = table( + columns: 3, + stroke: (red, blue, green), + [a], [b], [c], + [d], [e], [f], + [h], [i], [j], +) +#t +#set text(dir: rtl) +#t + +--- +#grid( + columns: 3, + inset: 3pt, + stroke: (x, _) => (right: (5pt, (dash: "dotted")).at(calc.rem(x, 2)), bottom: (dash: "densely-dotted")), + grid.vline(x: 0, stroke: red), + grid.vline(x: 1, stroke: red), + grid.vline(x: 2, stroke: red), + grid.vline(x: 3, stroke: red), + grid.hline(y: 0, end: 1, stroke: blue), + grid.hline(y: 1, end: 1, stroke: blue), + grid.cell[a], + [b], [c] +) + +--- +#set page(height: 5em) +#table( + columns: 3, + inset: 3pt, + table.hline(y: 0, end: none, stroke: 3pt + blue), + table.vline(x: 0, end: none, stroke: 3pt + green), + table.hline(y: 5, end: none, stroke: 3pt + red), + table.vline(x: 3, end: none, stroke: 3pt + yellow), + [a], [b], [c], + [a], [b], [c], + [a], [b], [c], + [a], [b], [c], + [a], [b], [c], +) + +--- +// Automatically positioned lines +// Plus stroke thickness ordering +#table( + columns: 3, + table.hline(stroke: red + 5pt), + table.vline(stroke: blue + 5pt), + table.vline(stroke: 2pt), + [a], + table.vline(x: 1, stroke: aqua + 5pt), + [b], + table.vline(stroke: aqua + 5pt), + [c], + table.vline(stroke: yellow + 5.2pt), + table.hline(stroke: green + 5pt), + [a], [b], [c], + [a], table.hline(stroke: green + 2pt), table.vline(stroke: 2pt), [b], [c], +) + +--- +// Line specification order priority +// The last line should be blue, not red. +// The middle line should have disappeared. +#grid( + columns: 2, + inset: 2pt, + grid.hline(y: 2, stroke: red + 5pt), + grid.vline(), + [a], [b], + grid.hline(stroke: red), + grid.hline(stroke: none), + [c], grid.cell(stroke: (top: aqua))[d], + grid.hline(stroke: blue), +) + +--- +// Position: bottom and position: end with gutter should have a visible effect +// of moving the lines after the next track. +#table( + columns: 3, + gutter: 3pt, + stroke: blue, + table.hline(end: 2, stroke: red), + table.hline(end: 2, stroke: aqua, position: bottom), + table.vline(end: 2, stroke: green), [a], table.vline(end: 2, stroke: green), table.vline(end: 2, position: end, stroke: orange), [b], table.vline(end: 2, stroke: aqua, position: end), table.vline(end: 2, stroke: green), [c], table.vline(end: 2, stroke: green), + [d], [e], [f], + table.hline(end: 2, stroke: red), + [g], [h], [ie], + table.hline(end: 2, stroke: green), +) + +--- +// Using position: bottom and position: end without gutter should be the same +// as placing a line after the next track. +#table( + columns: 3, + stroke: blue, + table.hline(end: 2, stroke: red), + table.hline(end: 2, stroke: aqua, position: bottom), + table.vline(end: 2, stroke: green), [a], table.vline(end: 2, stroke: green), [b], table.vline(end: 2, stroke: aqua, position: end), table.vline(end: 2, stroke: green), [c], table.vline(end: 2, stroke: green), + table.hline(end: 2, stroke: 5pt), + [d], [e], [f], + table.hline(end: 2, stroke: red), + [g], [h], [i], + table.hline(end: 2, stroke: red), +) + +--- +// Test left and right for grid vlines. +#grid( + columns: 3, + inset: 5pt, + grid.vline(stroke: green, position: left), grid.vline(stroke: red, position: right), [a], + grid.vline(stroke: 2pt, position: left), grid.vline(stroke: red, position: right), [b], + grid.vline(stroke: 2pt, position: left), grid.vline(stroke: red, position: right), [c], + grid.vline(stroke: 2pt, position: left) +) + +#grid( + columns: 3, + inset: 5pt, + gutter: 3pt, + grid.vline(stroke: green, position: left), grid.vline(stroke: red, position: right), [a], + grid.vline(stroke: blue, position: left), grid.vline(stroke: red, position: right), [b], + grid.vline(stroke: blue, position: left), grid.vline(stroke: red, position: right), [c], + grid.vline(stroke: 2pt, position: left) +) + +--- +// Test left and right for table vlines. +#table( + columns: 3, + inset: 5pt, + table.vline(stroke: green, position: left), table.vline(stroke: red, position: right), [a], + table.vline(stroke: 2pt, position: left), table.vline(stroke: red, position: right), [b], + table.vline(stroke: 2pt, position: left), table.vline(stroke: red, position: right), [c], + table.vline(stroke: 2pt, position: left) +) + +#table( + columns: 3, + inset: 5pt, + gutter: 3pt, + table.vline(stroke: green, position: left), table.vline(stroke: red, position: right), [a], + table.vline(stroke: blue, position: left), table.vline(stroke: red, position: right), [b], + table.vline(stroke: blue, position: left), table.vline(stroke: red, position: right), [c], + table.vline(stroke: 2pt, position: left) +) + +--- +// Hlines and vlines should always appear on top of cell strokes. +#table( + columns: 3, + stroke: aqua, + table.vline(stroke: red, position: end), [a], table.vline(stroke: red), [b], [c], + table.cell(stroke: blue)[d], [e], [f], + table.hline(stroke: red), + [g], table.cell(stroke: blue)[h], [i], +) + +#table( + columns: 3, + gutter: 3pt, + stroke: aqua, + table.vline(stroke: red, position: end), [a], table.vline(stroke: red), [b], [c], + table.cell(stroke: blue)[d], [e], [f], + table.hline(stroke: red), + [g], table.cell(stroke: blue)[h], [i], +) + +--- +// Ensure cell stroke overrides always appear on top. +#table( + columns: 2, + stroke: black, + table.cell(stroke: red)[a], [b], + [c], [d], +) + +#table( + columns: 2, + table.cell(stroke: red)[a], [b], + [c], [d], +) + +--- +// Error: 7:3-7:32 cannot place horizontal line at the 'bottom' position of the bottom border (y = 2) +// Hint: 7:3-7:32 set the line's position to 'top' or place it at a smaller 'y' index +#table( + columns: 2, + [a], [b], + [c], [d], + table.hline(stroke: aqua), + table.hline(position: top), + table.hline(position: bottom) +) + +--- +// 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( + columns: 2, + gutter: 3pt, + [a], [b], + [c], [d], table.vline(stroke: red), + table.hline(stroke: aqua), + table.hline(position: top), + table.hline(position: bottom) +) + +--- +// Error: 6:3-6:28 cannot place vertical line at the 'end' position of the end border (x = 2) +// Hint: 6:3-6:28 set the line's position to 'start' or place it at a smaller 'x' index +#grid( + columns: 2, + [a], [b], + grid.vline(stroke: aqua), + grid.vline(position: start), + grid.vline(position: end) +) + +--- +// Error: 7:3-7:28 cannot place vertical line at the 'end' position of the end border (x = 2) +// Hint: 7:3-7:28 set the line's position to 'start' or place it at a smaller 'x' index +#grid( + columns: 2, + gutter: 3pt, + [a], [b], + grid.vline(stroke: aqua), + grid.vline(position: start), + grid.vline(position: end) +) + +--- +// Error: 4:3-4:19 cannot place horizontal line at invalid row 3 +#grid( + [a], + [b], + grid.hline(y: 3) +) + +--- +// Error: 5:3-5:19 cannot place horizontal line at invalid row 3 +#grid( + gutter: 3pt, + [a], + [b], + grid.hline(y: 3) +) + +--- +// Error: 4:3-4:20 cannot place vertical line at invalid column 3 +#table( + columns: 2, + [a], [b], + table.vline(x: 3) +) + +--- +// Error: 5:3-5:20 cannot place vertical line at invalid column 3 +#table( + columns: 2, + gutter: 3pt, + [a], [b], + table.vline(x: 3) +) + +--- +// Error: 3:3-3:31 line cannot end before it starts +#grid( + columns: 3, + grid.hline(start: 2, end: 1), + [a], [b], [c], +) + +--- +// Error: 3:3-3:32 line cannot end before it starts +#table( + columns: 3, + table.vline(start: 2, end: 1), + [a], [b], [c], + [d], [e], [f], + [g], [h], [i], +) + +--- +// Error: 24-31 expected `top` or `bottom`, found horizon +#table.hline(position: horizon) + +--- +// Error: 24-30 expected `start`, `left`, `right`, or `end`, found center +#table.vline(position: center) + +--- +// Error: 24-29 expected `top` or `bottom`, found right +#table.hline(position: right) + +--- +// Error: 24-27 expected `start`, `left`, `right`, or `end`, found top +#table.vline(position: top)