diff options
Diffstat (limited to 'crates/typst-library/src/model/table.rs')
| -rw-r--r-- | crates/typst-library/src/model/table.rs | 724 |
1 files changed, 724 insertions, 0 deletions
diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs new file mode 100644 index 00000000..7dfaf45d --- /dev/null +++ b/crates/typst-library/src/model/table.rs @@ -0,0 +1,724 @@ +use std::num::NonZeroUsize; +use std::sync::Arc; + +use typst_utils::NonZeroExt; + +use crate::diag::{bail, HintedStrResult, HintedString, SourceResult}; +use crate::engine::Engine; +use crate::foundations::{ + cast, elem, scope, Content, NativeElement, Packed, Show, Smart, StyleChain, +}; +use crate::layout::{ + show_grid_cell, Abs, Alignment, BlockElem, Celled, GridCell, GridFooter, GridHLine, + GridHeader, GridVLine, Length, OuterHAlignment, OuterVAlignment, Rel, Sides, + TrackSizings, +}; +use crate::model::Figurable; +use crate::text::LocalName; +use crate::visualize::{Paint, Stroke}; + +/// A table of items. +/// +/// Tables are used to arrange content in cells. Cells can contain arbitrary +/// content, including multiple paragraphs and are specified in row-major order. +/// For a hands-on explanation of all the ways you can use and customize tables +/// in Typst, check out the [table guide]($guides/table-guide). +/// +/// Because tables are just grids with different defaults for some cell +/// properties (notably `stroke` and `inset`), refer to the [grid +/// documentation]($grid) for more information on how to size the table tracks +/// and specify the cell appearance properties. +/// +/// If you are unsure whether you should be using a table or a grid, consider +/// whether the content you are arranging semantically belongs together as a set +/// of related data points or similar or whether you are just want to enhance +/// your presentation by arranging unrelated content in a grid. In the former +/// case, a table is the right choice, while in the latter case, a grid is more +/// appropriate. Furthermore, Typst will annotate its output in the future such +/// that screenreaders will announce content in `table` as tabular while a +/// grid's content will be announced no different than multiple content blocks +/// in the document flow. +/// +/// Note that, to override a particular cell's properties or apply show rules on +/// table cells, you can use the [`table.cell`]($table.cell) element. See its +/// documentation for more information. +/// +/// Although the `table` and the `grid` share most properties, set and show +/// rules on one of them do not affect the other. +/// +/// To give a table a caption and make it [referenceable]($ref), put it into a +/// [figure]. +/// +/// # Example +/// +/// The example below demonstrates some of the most common table options. +/// ```example +/// #table( +/// columns: (1fr, auto, auto), +/// inset: 10pt, +/// align: horizon, +/// table.header( +/// [], [*Volume*], [*Parameters*], +/// ), +/// image("cylinder.svg"), +/// $ pi h (D^2 - d^2) / 4 $, +/// [ +/// $h$: height \ +/// $D$: outer radius \ +/// $d$: inner radius +/// ], +/// image("tetrahedron.svg"), +/// $ sqrt(2) / 12 a^3 $, +/// [$a$: edge length] +/// ) +/// ``` +/// +/// Much like with grids, you can use [`table.cell`]($table.cell) to customize +/// the appearance and the position of each cell. +/// +/// ```example +/// >>> #set page(width: auto) +/// >>> #set text(font: "IBM Plex Sans") +/// >>> #let gray = rgb("#565565") +/// >>> +/// #set table( +/// stroke: none, +/// gutter: 0.2em, +/// fill: (x, y) => +/// if x == 0 or y == 0 { gray }, +/// inset: (right: 1.5em), +/// ) +/// +/// #show table.cell: it => { +/// if it.x == 0 or it.y == 0 { +/// set text(white) +/// strong(it) +/// } else if it.body == [] { +/// // Replace empty cells with 'N/A' +/// pad(..it.inset)[_N/A_] +/// } else { +/// it +/// } +/// } +/// +/// #let a = table.cell( +/// fill: green.lighten(60%), +/// )[A] +/// #let b = table.cell( +/// fill: aqua.lighten(60%), +/// )[B] +/// +/// #table( +/// columns: 4, +/// [], [Exam 1], [Exam 2], [Exam 3], +/// +/// [John], [], a, [], +/// [Mary], [], a, a, +/// [Robert], b, a, b, +/// ) +/// ``` +#[elem(scope, Show, LocalName, Figurable)] +pub struct TableElem { + /// The column sizes. See the [grid documentation]($grid) for more + /// information on track sizing. + #[borrowed] + pub columns: TrackSizings, + + /// The row sizes. See the [grid documentation]($grid) for more information + /// on track sizing. + #[borrowed] + pub rows: TrackSizings, + + /// The gaps between rows and columns. This is a shorthand for setting + /// `column-gutter` and `row-gutter` to the same value. See the [grid + /// documentation]($grid) for more information on gutters. + #[external] + pub gutter: TrackSizings, + + /// The gaps between columns. Takes precedence over `gutter`. See the + /// [grid documentation]($grid) for more information on gutters. + #[borrowed] + #[parse( + let gutter = args.named("gutter")?; + args.named("column-gutter")?.or_else(|| gutter.clone()) + )] + pub column_gutter: TrackSizings, + + /// The gaps between rows. Takes precedence over `gutter`. See the + /// [grid documentation]($grid) for more information on gutters. + #[parse(args.named("row-gutter")?.or_else(|| gutter.clone()))] + #[borrowed] + pub row_gutter: TrackSizings, + + /// How to fill the cells. + /// + /// This can be a color or a function that returns a color. The function + /// receives the cells' column and row indices, starting from zero. This can + /// be used to implement striped tables. + /// + /// ```example + /// #table( + /// fill: (x, _) => + /// if calc.odd(x) { luma(240) } + /// else { white }, + /// align: (x, y) => + /// if y == 0 { center } + /// else if x == 0 { left } + /// else { right }, + /// columns: 4, + /// [], [*Q1*], [*Q2*], [*Q3*], + /// [Revenue:], [1000 €], [2000 €], [3000 €], + /// [Expenses:], [500 €], [1000 €], [1500 €], + /// [Profit:], [500 €], [1000 €], [1500 €], + /// ) + /// ``` + #[borrowed] + pub fill: Celled<Option<Paint>>, + + /// How to align the cells' content. + /// + /// This can either be a single alignment, an array of alignments + /// (corresponding to each column) or a function that returns an alignment. + /// The function receives the cells' column and row indices, starting from + /// zero. If set to `{auto}`, the outer alignment is used. + /// + /// ```example + /// #table( + /// columns: 3, + /// align: (left, center, right), + /// [Hello], [Hello], [Hello], + /// [A], [B], [C], + /// ) + /// ``` + #[borrowed] + pub align: Celled<Smart<Alignment>>, + + /// How to [stroke] the cells. + /// + /// Strokes can be disabled by setting this to `{none}`. + /// + /// 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.stroke) for more information on + /// strokes. + #[resolve] + #[fold] + #[default(Celled::Value(Sides::splat(Some(Some(Arc::new(Stroke::default()))))))] + pub stroke: Celled<Sides<Option<Option<Arc<Stroke>>>>>, + + /// How much to pad the cells' content. + /// + /// ```example + /// #table( + /// inset: 10pt, + /// [Hello], + /// [World], + /// ) + /// + /// #table( + /// columns: 2, + /// inset: ( + /// x: 20pt, + /// y: 10pt, + /// ), + /// [Hello], + /// [World], + /// ) + /// ``` + #[fold] + #[default(Celled::Value(Sides::splat(Some(Abs::pt(5.0).into()))))] + pub inset: Celled<Sides<Option<Rel<Length>>>>, + + /// 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<TableChild>, +} + +#[scope] +impl TableElem { + #[elem] + type TableCell; + + #[elem] + type TableHLine; + + #[elem] + type TableVLine; + + #[elem] + type TableHeader; + + #[elem] + type TableFooter; +} + +impl Show for Packed<TableElem> { + fn show(&self, engine: &mut Engine, _: StyleChain) -> SourceResult<Content> { + Ok(BlockElem::multi_layouter(self.clone(), engine.routines.layout_table) + .pack() + .spanned(self.span())) + } +} + +impl LocalName for Packed<TableElem> { + const KEY: &'static str = "table"; +} + +impl Figurable for Packed<TableElem> {} + +/// Any child of a table element. +#[derive(Debug, PartialEq, Clone, Hash)] +pub enum TableChild { + Header(Packed<TableHeader>), + Footer(Packed<TableFooter>), + Item(TableItem), +} + +cast! { + TableChild, + self => match self { + Self::Header(header) => header.into_value(), + Self::Footer(footer) => footer.into_value(), + Self::Item(item) => item.into_value(), + }, + v: Content => { + v.try_into()? + }, +} + +impl TryFrom<Content> for TableChild { + type Error = HintedString; + + fn try_from(value: Content) -> HintedStrResult<Self> { + if value.is::<GridHeader>() { + bail!( + "cannot use `grid.header` as a table header"; + hint: "use `table.header` instead" + ) + } + if value.is::<GridFooter>() { + bail!( + "cannot use `grid.footer` as a table footer"; + hint: "use `table.footer` instead" + ) + } + + value + .into_packed::<TableHeader>() + .map(Self::Header) + .or_else(|value| value.into_packed::<TableFooter>().map(Self::Footer)) + .or_else(|value| TableItem::try_from(value).map(Self::Item)) + } +} + +/// A table item, which is the basic unit of table specification. +#[derive(Debug, PartialEq, Clone, Hash)] +pub enum TableItem { + HLine(Packed<TableHLine>), + VLine(Packed<TableVLine>), + Cell(Packed<TableCell>), +} + +cast! { + TableItem, + self => match self { + Self::HLine(hline) => hline.into_value(), + Self::VLine(vline) => vline.into_value(), + Self::Cell(cell) => cell.into_value(), + }, + v: Content => { + v.try_into()? + }, +} + +impl TryFrom<Content> for TableItem { + type Error = HintedString; + + fn try_from(value: Content) -> HintedStrResult<Self> { + if value.is::<GridHeader>() { + bail!("cannot place a grid header within another header or footer"); + } + if value.is::<TableHeader>() { + bail!("cannot place a table header within another header or footer"); + } + if value.is::<GridFooter>() { + bail!("cannot place a grid footer within another footer or header"); + } + if value.is::<TableFooter>() { + bail!("cannot place a table footer within another footer or header"); + } + if value.is::<GridCell>() { + bail!( + "cannot use `grid.cell` as a table cell"; + hint: "use `table.cell` instead" + ); + } + if value.is::<GridHLine>() { + bail!( + "cannot use `grid.hline` as a table line"; + hint: "use `table.hline` instead" + ); + } + if value.is::<GridVLine>() { + bail!( + "cannot use `grid.vline` as a table line"; + hint: "use `table.vline` instead" + ); + } + + Ok(value + .into_packed::<TableHLine>() + .map(Self::HLine) + .or_else(|value| value.into_packed::<TableVLine>().map(Self::VLine)) + .or_else(|value| value.into_packed::<TableCell>().map(Self::Cell)) + .unwrap_or_else(|value| { + let span = value.span(); + Self::Cell(Packed::new(TableCell::new(value)).spanned(span)) + })) + } +} + +/// A repeatable table header. +/// +/// You should wrap your tables' heading rows in this function even if you do not +/// plan to wrap your table across pages because Typst will use this function to +/// attach accessibility metadata to tables in the future and ensure universal +/// access to your document. +/// +/// You can use the `repeat` parameter to control whether your table's header +/// will be repeated across pages. +/// +/// ```example +/// #set page(height: 11.5em) +/// #set table( +/// fill: (x, y) => +/// if x == 0 or y == 0 { +/// gray.lighten(40%) +/// }, +/// align: right, +/// ) +/// +/// #show table.cell.where(x: 0): strong +/// #show table.cell.where(y: 0): strong +/// +/// #table( +/// columns: 4, +/// table.header( +/// [], [Blue chip], +/// [Fresh IPO], [Penny st'k], +/// ), +/// table.cell( +/// rowspan: 6, +/// align: horizon, +/// rotate(-90deg, reflow: true)[ +/// *USD / day* +/// ], +/// ), +/// [0.20], [104], [5], +/// [3.17], [108], [4], +/// [1.59], [84], [1], +/// [0.26], [98], [15], +/// [0.01], [195], [4], +/// [7.34], [57], [2], +/// ) +/// ``` +#[elem(name = "header", title = "Table Header")] +pub struct TableHeader { + /// Whether this header should be repeated across pages. + #[default(true)] + pub repeat: bool, + + /// The cells and lines within the header. + #[variadic] + pub children: Vec<TableItem>, +} + +/// A repeatable table footer. +/// +/// Just like the [`table.header`]($table.header) element, the footer can repeat +/// itself on every page of the table. This is useful for improving legibility +/// by adding the column labels in both the header and footer of a large table, +/// totals, or other information that should be visible on every page. +/// +/// No other table cells may be placed after the footer. +#[elem(name = "footer", title = "Table Footer")] +pub struct TableFooter { + /// Whether this footer should be repeated across pages. + #[default(true)] + pub repeat: bool, + + /// The cells and lines within the footer. + #[variadic] + pub children: Vec<TableItem>, +} + +/// A horizontal line in the table. +/// +/// 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`]($table.column-gutter) option. +/// +/// Use this function instead of the table's `stroke` field if you want to +/// manually place a horizontal line at a specific position in a single table. +/// Consider using [table's `stroke`]($table.stroke) field or [`table.cell`'s +/// `stroke`]($table.cell.stroke) field instead if the line you want to place is +/// part of all your tables' designs. +/// +/// ```example +/// #set table.hline(stroke: .6pt) +/// +/// #table( +/// stroke: none, +/// columns: (auto, 1fr), +/// [09:00], [Badge pick up], +/// [09:45], [Opening Keynote], +/// [10:30], [Talk: Typst's Future], +/// [11:15], [Session: Good PRs], +/// table.hline(start: 1), +/// [Noon], [_Lunch break_], +/// table.hline(start: 1), +/// [14:00], [Talk: Tracked Layout], +/// [15:00], [Talk: Automations], +/// [16:00], [Workshop: Tables], +/// table.hline(), +/// [19:00], [Day 1 Attendee Mixer], +/// ) +/// ``` +#[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.y). + pub y: Smart<usize>, + + /// 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<NonZeroUsize>, + + /// The line's stroke. + /// + /// Specifying `{none}` removes any lines previously placed across this + /// line's range, including hlines or per-cell stroke below it. + #[resolve] + #[fold] + #[default(Some(Arc::new(Stroke::default())))] + pub stroke: Option<Arc<Stroke>>, + + /// 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`]($table.row-gutter) option. +/// +/// Similar to [`table.hline`]($table.hline), use this function if you want to +/// manually place a vertical line at a specific position in a single table and +/// use the [table's `stroke`]($table.stroke) field or [`table.cell`'s +/// `stroke`]($table.cell.stroke) field instead if the line you want to place is +/// part of all your tables' designs. +#[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<usize>, + + /// 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<NonZeroUsize>, + + /// The line's stroke. + /// + /// Specifying `{none}` removes any lines previously placed across this + /// line's range, including vlines or per-cell stroke below it. + #[resolve] + #[fold] + #[default(Some(Arc::new(Stroke::default())))] + pub stroke: Option<Arc<Stroke>>, + + /// 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 position a cell manually or to apply +/// styling. To do the latter, you can either use the function to override the +/// properties for a particular cell, or use it in show rules to apply certain +/// styles to multiple cells at once. +/// +/// Perhaps the most important use case of `{table.cell}` is to make a cell span +/// multiple columns and/or rows with the `colspan` and `rowspan` fields. +/// +/// ```example +/// >>> #set page(width: auto) +/// #show table.cell.where(y: 0): strong +/// #set table( +/// stroke: (x, y) => if y == 0 { +/// (bottom: 0.7pt + black) +/// }, +/// align: (x, y) => ( +/// if x > 0 { center } +/// else { left } +/// ) +/// ) +/// +/// #table( +/// columns: 3, +/// table.header( +/// [Substance], +/// [Subcritical °C], +/// [Supercritical °C], +/// ), +/// [Hydrochloric Acid], +/// [12.0], [92.1], +/// [Sodium Myreth Sulfate], +/// [16.6], [104], +/// [Potassium Hydroxide], +/// table.cell(colspan: 2)[24.7], +/// ) +/// ``` +/// +/// For example, you can override the fill, alignment or inset for a single +/// cell: +/// +/// ```example +/// >>> #set page(width: auto) +/// // You can also import those. +/// #import table: cell, header +/// +/// #table( +/// columns: 2, +/// align: center, +/// header( +/// [*Trip progress*], +/// [*Itinerary*], +/// ), +/// cell( +/// align: right, +/// fill: fuchsia.lighten(80%), +/// [🚗], +/// ), +/// [Get in, folks!], +/// [🚗], [Eat curbside hotdog], +/// cell(align: left)[🌴🚗], +/// cell( +/// inset: 0.06em, +/// text(1.62em)[🛖🌅🌊], +/// ), +/// ) +/// ``` +/// +/// You may also apply a show rule on `table.cell` to style all cells at once. +/// Combined with selectors, this allows you to apply styles based on a cell's +/// position: +/// +/// ```example +/// #show table.cell.where(x: 0): strong +/// +/// #table( +/// columns: 3, +/// gutter: 3pt, +/// [Name], [Age], [Strength], +/// [Hannes], [36], [Grace], +/// [Irma], [50], [Resourcefulness], +/// [Vikram], [49], [Perseverance], +/// ) +/// ``` +#[elem(name = "cell", title = "Table Cell", Show)] +pub struct TableCell { + /// The cell's body. + #[required] + pub body: Content, + + /// The cell's column (zero-indexed). + /// Functions identically to the `x` field in [`grid.cell`]($grid.cell). + pub x: Smart<usize>, + + /// The cell's row (zero-indexed). + /// Functions identically to the `y` field in [`grid.cell`]($grid.cell). + pub y: Smart<usize>, + + /// The amount of columns spanned by this cell. + #[default(NonZeroUsize::ONE)] + pub colspan: NonZeroUsize, + + /// The amount of rows spanned by this cell. + #[default(NonZeroUsize::ONE)] + pub rowspan: NonZeroUsize, + + /// The cell's [fill]($table.fill) override. + pub fill: Smart<Option<Paint>>, + + /// The cell's [alignment]($table.align) override. + pub align: Smart<Alignment>, + + /// The cell's [inset]($table.inset) override. + pub inset: Smart<Sides<Option<Rel<Length>>>>, + + /// The cell's [stroke]($table.stroke) override. + #[resolve] + #[fold] + pub stroke: Sides<Option<Option<Arc<Stroke>>>>, + + /// Whether rows spanned by this cell can be placed in different pages. + /// When equal to `{auto}`, a cell spanning only fixed-size rows is + /// unbreakable, while a cell spanning at least one `{auto}`-sized row is + /// breakable. + pub breakable: Smart<bool>, +} + +cast! { + TableCell, + v: Content => v.into(), +} + +impl Show for Packed<TableCell> { + fn show(&self, _engine: &mut Engine, styles: StyleChain) -> SourceResult<Content> { + show_grid_cell(self.body().clone(), self.inset(styles), self.align(styles)) + } +} + +impl Default for Packed<TableCell> { + fn default() -> Self { + Packed::new(TableCell::new(Content::default())) + } +} + +impl From<Content> for TableCell { + fn from(value: Content) -> Self { + #[allow(clippy::unwrap_or_default)] + value.unpack::<Self>().unwrap_or_else(Self::new) + } +} |
