diff --git a/packages/preview/tablex/0.0.8/LICENSE b/packages/preview/tablex/0.0.8/LICENSE new file mode 100644 index 000000000..762c85ecb --- /dev/null +++ b/packages/preview/tablex/0.0.8/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Pg Biel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/preview/tablex/0.0.8/README.md b/packages/preview/tablex/0.0.8/README.md new file mode 100644 index 000000000..abd9549ef --- /dev/null +++ b/packages/preview/tablex/0.0.8/README.md @@ -0,0 +1,828 @@ +# typst-tablex (v0.0.8) +**More powerful and customizable tables in Typst.** + +**NOTE: Please open an issue if you find a bug with tablex** and I'll get to it as soon as I can. **(PRs are also welcome!)** + +## Sponsors ❤️ + +If you'd like to appear here, [consider sponsoring the project!](https://github.com/sponsors/PgBiel) + + +

+felipeacsi +

+ + + + +## Table of Contents + +* [Usage](#usage) +* [Features](#features) + * [_Almost_ drop-in replacement for `#table`](#almost-drop-in-replacement-for-table) + * [colspanx/rowspanx](#colspanxrowspanx) + * [Repeat header rows](#repeat-header-rows) + * [Customize every single line](#customize-every-single-line) + * [Customize every single cell](#customize-every-single-cell) +* [Known Issues](#known-issues) +* [Reference](#reference) + * [Basic types and functions](#basic-types-and-functions) + * [Gridx and Tablex](#gridx-and-tablex) +* [Changelog](#changelog) + * [v0.0.8](#v008) + * [v0.0.7](#v007) + * [v0.0.6](#v006) + * [v0.0.5](#v005) + * [v0.0.4](#v004) + * [v0.0.3](#v003) + * [v0.0.2](#v002) + * [v0.0.1](#v001) +* [0.1.0 Roadmap](#010-roadmap) +* [License](#license) + +## Usage + +To use this library through the Typst package manager **(for Typst v0.6.0+)**, write for example `#import "@preview/tablex:0.0.8": tablex, cellx` at the top of your Typst file (you may also add whichever other functions you use from the library to that import list!). + +For older Typst versions, download the file `tablex.typ` from the latest release (or directly from the main branch, for the 'bleeding edge') at the tablex repository (https://github.com/PgBiel/typst-tablex) and place it on the same folder as your own Typst file. Then, at the top of your file, write for example `#import "tablex.typ": tablex, cellx` (plus whichever other functions you use from the library). + +This library should be compatible with Typst v0.2.0, v0.3.0, v0.4.0, v0.5.0, v0.6.0, v0.7.0, v0.8.0, v0.9.0 and v0.10.0. +**Using the latest Typst version is always recommended** in order to make use of the latest optimizations and features available. + +Here's an example of what `tablex` can do: + +![image](https://github.com/PgBiel/typst-tablex/assets/9021226/355c527a-7296-4264-bac7-4ec991b15a18) + +Here's the code for that table: +```typ +#import "@preview/tablex:0.0.8": tablex, rowspanx, colspanx + +#tablex( + columns: 4, + align: center + horizon, + auto-vlines: false, + + // indicate the first two rows are the header + // (in case we need to eventually + // enable repeating the header across pages) + header-rows: 2, + + // color the last column's cells + // based on the written number + map-cells: cell => { + if cell.x == 3 and cell.y > 1 { + cell.content = { + let value = int(cell.content.text) + let text-color = if value < 10 { + red.lighten(30%) + } else if value < 15 { + yellow.darken(13%) + } else { + green + } + set text(text-color) + strong(cell.content) + } + } + cell + }, + + /* --- header --- */ + rowspanx(2)[*Username*], colspanx(2)[*Data*], (), rowspanx(2)[*Score*], + (), [*Location*], [*Height*], (), + /* -------------- */ + + [John], [Second St.], [180 cm], [5], + [Wally], [Third Av.], [160 cm], [10], + [Jason], [Some St.], [150 cm], [15], + [Robert], [123 Av.], [190 cm], [20], + [Other], [Unknown St.], [170 cm], [25], +) +``` + +## Features + +### _Almost_ drop-in replacement for `#table` + +In most cases, you should be able to replace `#table` with `#tablex` and be good to go for a start - it should look _very_ similar (if not identical). Indeed, the syntax is very similar for the basics: + +```typ +#import "@preview/tablex:0.0.8": tablex + +#tablex( + columns: (auto, 1em, 1fr, 1fr), // 4 columns + rows: auto, // at least 1 row of auto size + fill: red, + align: center + horizon, + stroke: green, + [a], [b], [c], [d], + [e], [f], [g], [h], + [i], [j], [k], [l] +) +``` + +![image](https://user-images.githubusercontent.com/9021226/230818397-2d599324-32a5-4184-973f-2fcfb6b62c84.png) + +There are still a few oddities in the library (see [Known Issues](#known-issues) for more info), but, for the vast majority of cases, replacing `#tablex` by `#table` should work just fine. (Sometimes you can even replace `#grid` by `#gridx` - see the line customization section for more -, but not always, as the behavior is a bit different.) + +This is mostly a word of caution in case anything I haven't anticipated happens, but, based on my tests (and after tons of bug-fixing commits), the vast majority of tables (that don't face one of the listed known issues) should work just fine under the library. + +**Note:** If your document is written in a right-to-left (RTL) script, you may wish to enable `rtl: true` for your tables so that the order of cells and lines properly follows your text direction (when combined with `set text(dir: rtl)`). This is necessary because tablex cannot detect that setting automatically at the moment (while the native Typst table can and flips itself horizontally automatically). See the tablex option reference for more information. + +### colspanx/rowspanx + +Your cells can now span more than one column and/or row at once, with `colspanx` / `rowspanx`: + +```typ +#import "@preview/tablex:0.0.8": tablex, colspanx, rowspanx + +#tablex( + columns: 3, + colspanx(2)[a], (), [b], + [c], rowspanx(2)[d], [ed], + [f], (), [g] +) +``` + +![image](https://user-images.githubusercontent.com/9021226/230810720-fbdfdbe5-8568-42ed-b8a2-5eff332a89d6.png) + +Note that the empty parentheses there are just for organization, and are ignored (unless they come before the first cell - more on that later). They're useful to help us keep track of which cell positions are being used up by the spans, because, if we try to add an actual cell at these spots, it will just push the others forward, which might seem unexpected. + +Use `colspanx(2, rowspanx(2)[d])` to colspan and rowspan at the same time. Be careful not to attempt to overwrite other cells' spans, as you will get a nasty error. + +**Note (since tablex v0.0.8):** By default, colspans and rowspans can cause spanned `auto` columns and rows to expand to fit their contents (only the last spanned track - column or row - can expand). If you'd like colspans to not affect column sizes at all (and thus "fit" within their spanned columns), you may specify `fit-spans: (x: true)` to the table. Similarly, you can specify `fit-spans: (y: true)` to have rowspans not affect row sizes at all. To apply both effects, use either `fit-spans: true` or `fit-spans: (x: true, y: true)`. You can also apply this to a single colspan (for example) with `colspanx(2, fit-spans: (x: true))[a]`, as this option is available not only for the whole table but also for each cell. See the reference section for more information. + +### Repeat header rows + +You can now ensure the first row (or, rather, the rows covered by the first rowspan) in your table repeats across pages. Just use `repeat-header: true` (default is `false`). + +Note that you may wish to customize this. Use `repeat-header: 6` to repeat for 6 more pages. Use `repeat-header: (2, 4)` to repeat only on the 2nd and the 4th page (where the 1st page is the one the table starts in). Additionally, use `header-rows: 5` to ensure the first (e.g.) 5 rows are part of the header (by default, this is 1 - more rows will be repeated where necessary if rowspans are used). + +Also, note that, by default, the horizontal lines below the header are transported to other pages, which may be an annoyance if you customize lines too much (see below). Use `header-hlines-have-priority: false` to ensure that the first row in each page will dictate the appearance of the horizontal lines above it (and not the header). + +**Note:** Depending on the size of your document, repeatable headers might not behave properly due to certain limitations in Typst's introspection system (as observed in https://github.com/PgBiel/typst-tablex/issues/43). + +Example: + +```typ +#import "@preview/tablex:0.0.8": tablex, hlinex, vlinex, colspanx, rowspanx + +#pagebreak() +#v(80%) + +#tablex( + columns: 4, + align: center + horizon, + auto-vlines: false, + repeat-header: true, + + /* --- header --- */ + rowspanx(2)[*Names*], colspanx(2)[*Properties*], (), rowspanx(2)[*Creators*], + (), [*Type*], [*Size*], (), + /* -------------- */ + + [Machine], [Steel], [5 $"cm"^3$], [John p& Kate], + [Frog], [Animal], [6 $"cm"^3$], [Robert], + [Frog], [Animal], [6 $"cm"^3$], [Robert], + [Frog], [Animal], [6 $"cm"^3$], [Robert], + [Frog], [Animal], [6 $"cm"^3$], [Robert], + [Frog], [Animal], [6 $"cm"^3$], [Robert], + [Frog], [Animal], [6 $"cm"^3$], [Robert], + [Frog], [Animal], [6 $"cm"^3$], [Rodbert], +) +``` + +![image](https://user-images.githubusercontent.com/9021226/230810751-776a73c4-9c24-46ba-92cd-76292469ab7d.png) + + +### Customize every single line + +Every single line in the table is either a `hlinex` (horizontal) or `vlinex` (vertical) instance. By default, there is one between every column and between every row - unless you specify a custom line for some column or row, in which case the automatic line for it will be removed (to allow you to freely customize it). To disable this behavior, use `auto-lines: false`, which will remove _all_ automatic lines. You may also remove only automatic horizontal lines with `auto-hlines: false`, and only vertical with `auto-vlines: false`. + +**Note:** `gridx` is an alias for `tablex` with `auto-lines: false`. + +For your custom lines, write `hlinex()` at any position and it will add a horizontal line below the current cell row (or at the top, if before any cell). You can use `hlinex(start: a, end: b)` to control the cells which that line spans (`a` is the first column number and `b` is the last column number). You can also specify its stroke (color/thickness) with `hlinex(stroke: red + 5pt)` for example. To position it at an arbitrary row, use `hlinex(y: 6)` or similar. (Columns and rows are indexed starting from 0.) + +Something similar occurs for `vlinex()`, which has `start`, `end` (first row and last row it spans), and also `stroke`. They will, by default, be placed to the right of the current cell, and will span the entire table (top to bottom). To override the default placement, use `vlinex(x: 2)` or similar. + +**Note:** Only one hline or vline with the same span (same start/end) can be placed at once. + +**Note:** You can also place vlines before the first cell, in which case _they will be placed consecutively, each after the last `vlinex()`_. That is, if you place several of them in a row (*before the first cell* only), then it will not place all of them at one location (which is normally what happens if you try to place multiple vlines at once), but rather one after the other. With this behavior, you can also specify `()` between each vline to _skip_ certain positions (again, only before the first cell - afterwards, all `()` are ignored). Note that you can also just ignore this entirely and use `vlinex(x: 0)`, `vlinex(x: 1)`, ..., `vlinex(x: columns.len())` for full control. + +Here's some sample usage: + +```typ +#import "@preview/tablex:0.0.8": tablex, gridx, hlinex, vlinex, colspanx, rowspanx + +#tablex( + columns: 4, + auto-lines: false, + + // skip a column here vv + vlinex(), vlinex(), vlinex(), (), vlinex(), + colspanx(2)[a], (), [b], [J], + [c], rowspanx(2)[d], [e], [K], + [f], (), [g], [L], + // ^^ '()' after the first cell are 100% ignored +) + +#tablex( + columns: 4, + auto-vlines: false, + colspanx(2)[a], (), [b], [J], + [c], rowspanx(2)[d], [e], [K], + [f], (), [g], [L], +) + +#gridx( + columns: 4, + (), (), vlinex(end: 2), + hlinex(stroke: yellow + 2pt), + colspanx(2)[a], (), [b], [J], + hlinex(start: 0, end: 1, stroke: yellow + 2pt), + hlinex(start: 1, end: 2, stroke: green + 2pt), + hlinex(start: 2, end: 3, stroke: red + 2pt), + hlinex(start: 3, end: 4, stroke: blue.lighten(50%) + 2pt), + [c], rowspanx(2)[d], [e], [K], + hlinex(start: 2), + [f], (), [g], [L], +) +``` + +![image](https://user-images.githubusercontent.com/9021226/230817335-8a908d44-77be-45d2-b98f-89e9ccf07dc7.png) + +#### Bulk line customization + +You can also *bulk-customize lines* by specifying `map-hlines: h => new_hline` and `map-vlines: v => new_vline`. This includes any automatically generated lines. For example: + +```typ +#import "@preview/tablex:0.0.8": tablex, colspanx, rowspanx + +#tablex( + columns: 3, + map-hlines: h => (..h, stroke: blue), + map-vlines: v => (..v, stroke: green + 2pt), + colspanx(2)[a], (), [b], + [c], rowspanx(2)[d], [ed], + [f], (), [g] +) +``` + +![image](https://user-images.githubusercontent.com/9021226/235371652-48e7e526-1eb0-43c3-a6f4-3ed81840cffc.png) + + +### Customize every single cell + +Cells can be customized entirely. Instead of specifying content (e.g. `[text]`) as a table item, you can specify `cellx(property: a, property: b, ...)[text]`, which allows you to customize properties, such as: + +- `colspan: 2` (same as using `colspanx(2, ...)[...]`) +- `rowspan: 3` (same as using `rowspanx(3, ...)[...]`) +- `align: center` (override whole-table alignment for this cell) +- `fill: blue` (fill just this cell with that color) +- `inset: 0pt` (override inset/internal padding for this cell - note that this can look off unless you use auto columns and rows) +- `x: 5` (arbitrarily place the cell at the given column, beginning at 0 - may error if conflicts occur) +- `y: 6` (arbitrarily place the cell at the given row, beginning at 0 - may error if conflicts occur) + +Additionally, instead of specifying content to the cell, you can specify a function `(column, row) => content`, allowing each cell to be aware of where it's positioned. (Note that positions are recorded in the cell's `.x` and `.y` attributes, and start as `auto` unless you specify otherwise.) + +For example: + +```typ +#import "@preview/tablex:0.0.8": tablex, cellx, colspanx, rowspanx + +#tablex( + columns: 3, + fill: red, + align: right, + colspanx(2)[a], (), [beeee], + [c], rowspanx(2)[d], cellx(fill: blue, align: left)[e], + [f], (), [g], + + // place this cell at the first column, seventh row + cellx(colspan: 3, align: center, x: 0, y: 6)[hi I'm down here] +) +``` + +![image](https://user-images.githubusercontent.com/9021226/230818283-b3b636db-dbd0-47b8-bdd5-f61a07d58749.png) + +#### Bulk customization of cells + +To customize multiple cells at once, you have a few options: + +1. `map-cells: cell => cell` (given a cell, returns a new cell). You can use this to customize the cell's attributes, but also to change its positions (however, avoid doing that as it can easily generate conflicts). You can access the cell's position with `cell.x` and `cell.y`. All other attributes are also accessible and changeable (see the `Reference` further below for a list). Return something like `(..cell, fill: blue)`, for example, to ensure the other properties (including the cell type marker) are kept. (Calling `cellx` here is not necessary. If overriding the cell's content, use `content: [whatever]`). This is useful if you want to, for example, customize a cell's fill color based on its contents, or add some content to every cell, or something similar. + +2. `map-rows: (row_index, cells) => cells` (given a row index and all cells in it, return a new array of cells). Allows customizing entire rows, but note that the cells in the `cells` parameter can be `none` if they're some position occupied by a colspan or rowspan of another cell. Ensure you return the cells in the order you were given, including the `none`s, for best results. Also, you cannot move cells here to another row. You can change the cells' columns (by changing their `x` property), but that will certainly generate conflicts if any col/rowspans are involved (in general, you cannot bulk-change col/rowspans without `map-cells`). + +3. `map-cols: (col_index, cells) => cells` (given a column index and all cells in it, return a new array of cells). Similar to `map-rows`, but for customizing columns. You cannot change the column of any cell here. (To do that, `map-cells` is required.) You can, however, change its row (with `y`, but do that sparingly), and, of course, all other properties. + +**Note:** Execution order is `map-cells` => `map-rows` => `map-cols`. + +Example: + +```typ +#import "@preview/tablex:0.0.8": tablex, colspanx, rowspanx + +#tablex( + columns: 4, + auto-vlines: true, + + // make all cells italicized + map-cells: cell => { + (..cell, content: emph(cell.content)) + }, + + // add some arbitrary content to entire rows + map-rows: (row, cells) => cells.map(c => + if c == none { + c // keeping 'none' is important + } else { + (..c, content: [#c.content\ *R#row*]) + } + ), + + // color cells based on their columns + // (using 'fill: (column, row) => color' also works + // for this particular purpose) + map-cols: (col, cells) => cells.map(c => + if c == none { + c + } else { + (..c, fill: if col < 2 { blue } else { yellow }) + } + ), + + colspanx(2)[a], (), [b], [J], + [c], rowspanx(2)[dd], [e], [K], + [f], (), [g], [L], +) +``` + +![image](https://user-images.githubusercontent.com/9021226/230818347-30b49154-f444-4744-9415-dd4030b29393.png) + +Another example (summing columns): + +```typ +#gridx( + columns: 3, + rows: 6, + fill: (col, row) => (blue, red, green).at(calc.rem(row + col - 1, 3)), + map-cols: (col, cells) => { + let last = cells.last() + last.content = [ + #cells.slice(0, cells.len() - 1).fold(0, (acc, c) => if c != none { acc + eval(c.content.text) } else { acc }) + ] + last.fill = aqua + cells.last() = last + cells + }, + [0], [5], [10], + [1], [6], [11], + [2], [7], [12], + [3], [8], [13], + [4], [9], [14], + [s], [s], [s] +) +``` + +![image](https://user-images.githubusercontent.com/9021226/231343813-bf06872b-59ac-4221-b6ed-940d73e6a9c4.png) + +## Known Issues + +- Filled cells will partially overlap with horizontal lines above them (see https://github.com/PgBiel/typst-tablex/issues/4). + - To be fixed in a future rework of the table drawing process. + +- Table lines don't play very well with column and row gutter when a colspan or rowspan is used. They may be missing or be cut off by gutters. + +- Repeatable table headers might not behave properly depending on the size of your document or other factors (https://github.com/PgBiel/typst-tablex/issues/43). + +- Using tablex (especially when using repeatable header rows) may cause a warning, "layout did not converge within 5 attempts", to appear on recent Typst versions (https://github.com/PgBiel/typst-tablex/issues/38). This warning is due to how tablex works internally **and is not your fault** (in principle), so don't worry too much about it (unless you're sure it's not tablex that is causing this). + +- Rows with fractional height (such as `2fr`) have zero height if the table spans more than one page. This is because fractional row heights are calculated on the available height of the first page of the table, which is something that the default `#table` can circumvent using internal code. This won't be fixed for now. (Columns with fractional width work fine, provided all pages the table is in have the same width, **and the page width isn't `auto`** (which forces fractional columns to be 0pt, even in the default `#table`).) + +- Rotation (via Typst's `#rotate`) of text only affects the visual appearance of the text on the page, but does not change its dimensions as they factor into the layout. + This leads to certain visual issues, such as rotated text potentially overflowing the cell height without being hyphenated or, inversely, being hyphenated even though there is enough space vertically (https://github.com/PgBiel/typst-tablex/issues/59). + This is a [known issue](https://github.com/typst/typst/issues/528) with Typst (perhaps, in the future, `#rotate` [may](https://github.com/typst/typst/issues/528#issuecomment-1494123195) get a setting to affect layout). + As a workaround for the text hyphenation problem, the content can be boxed (and thus grouped together) with `#box` (e.g., `rowspanx(7, box(rotate(-90deg, [*donothyphenatethis*])))`), or hyphenation can be prevented by setting `#text(hyphenate: false, ...)` (e.g., `colspanx(2, text(hyphenate: false, rotate(-90deg, [*donothyphenatethis*])))`), as also discussed in https://github.com/PgBiel/typst-tablex/issues/59; + another alternative is to use `#place`, e.g. aligning to `center + horizon`: `cellx(place(center + horizon, rotate(-90deg, [*donothyphenatethis*])))`, which probably allows the most control over the in-cell layout, since it simply draws the rotated content without having it occupy any space (letting you define that by yourself, e.g. using `box(width: 1em, height: 2em, place(...))`). + - Alternatively, you may attempt to use the solution proposed at https://github.com/typst/typst/issues/528#issuecomment-1494318510 to define a `rotatex` function which produces a rotated element with the appropriate sizes, such that tablex may recognize its size accordingly and avoid visual glitches. + +- `tablex` can potentially be slower and/or take longer to compile than the default `table` (especially when the table spans a lot of pages). **Please use the latest Typst version to reduce this problem** (each version has been bringing further improvements in this sense). Still, we are looking for ways to better optimize the library (see more discussion at https://github.com/PgBiel/typst-tablex/issues/5 - feel free to give some input!). However, re-compilation is usually fine thanks to Typst's built-in memoization. + +- The internals of the library still aren't very well documented; I plan on adding more info about this eventually. + +- **Please open a GitHub issue for anything weird you come across** (make sure others haven't reported it first). + +## Reference + +### Basic types and functions + +1. `cellx`: Represents a table cell, and is initialized as follows: + + ```typ + #let cellx(content, + x: auto, y: auto, + rowspan: 1, colspan: 1, + fill: auto, align: auto, + inset: auto, + fit-spans: auto + ) = ( + tablex-dict-type: "cell", + content: content, + rowspan: rowspan, + colspan: colspan, + align: align, + fill: fill, + inset: inset, + fit-spans: fit-spans, + x: x, + y: y, + ) + ``` + where: + + - `tablex-dict-type` is the type marker + - `content` is the cell's content (either `content` or a function with `(col, row) => content`) + - `rowspan` is how many rows this cell spans (default 1) + - `colspan` is how many columns this cell spans (default 1) + - `align` is this cell's align override, such as "center" (default `auto` to follow the rest of the table) + - `fill` is this cell's fill override, such as "blue" (default `auto` to follow the rest of the table) + - `inset` is this cell's inset override, such as `5pt` (default `auto` to follow the rest of the table) + - `fit-spans` allows overriding the table-wide `fit-spans` setting for this specific cell (e.g. if this cell has a `colspan` greater than 1, `fit-spans: (x: true)` will cause it to not affect the sizes of `auto` columns). + - `x` is the cell's column index (0..len-1) - `auto` indicates it wasn't assigned yet + - `y` is the cell's row index (0..len-1) - `auto` indicates it wasn't assigned yet + +2. `hlinex`: represents a horizontal line: + + ```typ + #let hlinex( + start: 0, end: auto, y: auto, + stroke: auto, + stop-pre-gutter: auto, gutter-restrict: none, + stroke-expand: true, + expand: none + ) = ( + tablex-dict-type: "hline", + start: start, + end: end, + y: y, + stroke: stroke, + stop-pre-gutter: stop-pre-gutter, + gutter-restrict: gutter-restrict, + stroke-expand: stroke-expand, + expand: expand, + parent: none, + ) + ``` + + where: + + - `tablex-dict-type` is the type marker + - `start` is the column index where the hline starts from (default `0`, a.k.a. the beginning) + - `end` is the last column the hline touches (default `auto`, a.k.a. all the way to the end) + - Note that hlines will *not* be drawn over cells with `colspan` larger than 1, even if their spans (`start`-`end`) include that cell. + - `y` is the index of the row at the top of which the hline is drawn. (Defaults to `auto`, a.k.a. depends on where you placed the `hline` among the table items - it's always on the top of the row below the current one.) + - `stroke` is the hline's stroke override (defaults to `auto`, a.k.a. follow the rest of the table). + - `stop-pre-gutter`: When `true`, the hline will not be drawn over gutter (which is the default behavior of tables). Defaults to `auto` which is essentially `false` (draw over gutter). + - `gutter-restrict`: Either `top`, `bottom`, or `none`. Has no effect if `row-gutter` is set to `none`. Otherwise, defines if this `hline` should be drawn only on the top of the row gutter (`top`); on the bottom (`bottom`); or on both the top and the bottom (`none`, the default). Note that `top` and `bottom` are alignment values (not strings). + - `stroke-expand`: When `true`, the hline will be extended as necessary to cover the stroke of the vlines going through either end of the line. Defaults to `true`. + - `expand`: Optionally extend the hline by an arbitrary length. When `none`, it is not expanded. When a length (such as `5pt`), it is expanded by that length on both ends. When an array of two lengths (such as `(5pt, 10pt)`), it is expanded to the left by the first length (in this case, `5pt`) and to the right by the second (in this case, `10pt`). Defaults to `none`. + - `parent`: An internal attribute determined when splitting lines among cells. (It should always be `none` on user-facing interfaces.) + +3. `vlinex`: represents a vertical line: + + ```typ + #let vlinex( + start: 0, end: auto, x: auto, + stroke: auto, + stop-pre-gutter: auto, gutter-restrict: none, + stroke-expand: true, + expand: none + ) = ( + tablex-dict-type: "vline", + start: start, + end: end, + x: x, + stroke: stroke, + stop-pre-gutter: stop-pre-gutter, + gutter-restrict: gutter-restrict, + stroke-expand: stroke-expand, + expand: expand, + parent: none, + ) + ``` + + where: + + - `tablex-dict-type` is the type marker + - `start` is the row index where the vline starts from (default `0`, a.k.a. the top) + - `end` is the last row the vline touches (default `auto`, a.k.a. all the way to the bottom) + - Note that vlines will *not* be drawn over cells with `rowspan` larger than 1, even if their spans (`start`-`end`) include that cell. + - `x` is the index of the column to the left of which the vline is drawn. (Defaults to `auto`, a.k.a. depends on where you placed the `vline` among the table items.) + - For a `vline` to be placed after all columns, its `x` value will be equal to the amount of columns (which isn't a valid column index, but it's what is used here). + - `stroke` is the vline's stroke override (defaults to `auto`, a.k.a. follow the rest of the table). + - `stop-pre-gutter`: When `true`, the vline will not be drawn over gutter (which is the default behavior of tables). Defaults to `auto` which is essentially `false` (draw over gutter). + - `gutter-restrict`: Either `left`, `right`, or `none`. Has no effect if `column-gutter` is set to `none`. Otherwise, defines if this `vline` should be drawn only to the left of the column gutter (`left`); to the right (`right`); or on both the left and the right (`none`, the default). Note that `left` and `right` are alignment values (not strings). + - `stroke-expand`: When `true`, the vline will be extended as necessary to cover the stroke of the hlines going through either end of the line. Defaults to `true`. + - `expand`: Optionally extend the vline by an arbitrary length. When `none`, it is not expanded. When a length (such as `5pt`), it is expanded by that length on both ends. When an array of two lengths (such as `(5pt, 10pt)`), it is expanded towards the top by the first length (in this case, `5pt`) and towards the bottom by the second (in this case, `10pt`). Defaults to `none`. + - `parent`: An internal attribute determined when splitting lines among cells. (It should always be `none` on user-facing interfaces.) + +4. The `occupied` type is an internal type used to represent cell positions occupied by cells with `colspan` or `rowspan` greater than 1. + +5. Use `is-tablex-cell`, `is-tablex-hline`, `is-tablex-vline` and `is-tablex-occupied` to check if a particular object has the corresponding type marker. + +6. `colspanx` and `rowspanx` are shorthands for setting the `colspan` and `rowspan` attributes of `cellx`. They can also be nested (one given as an argument to the other) to combine their properties (e.g., `colspanx(2)(rowspanx(3)[a])`). They accept all other cell properties with named arguments. For example, `colspanx(2, align: center)[b]` is equivalent to `cellx(colspan: 2, align: center)[b]`. + +### Gridx and Tablex + +1. `gridx` is equivalent to `tablex` with `auto-lines: false`; see below. + +2. `tablex:` The main function for creating a table with this library: + + ```typ + #let tablex( + columns: auto, rows: auto, + inset: 5pt, + align: auto, + fill: none, + stroke: auto, + column-gutter: auto, row-gutter: auto, + gutter: none, + repeat-header: false, + header-rows: 1, + header-hlines-have-priority: true, + auto-lines: true, + auto-hlines: auto, + auto-vlines: auto, + map-cells: none, + map-hlines: none, + map-vlines: none, + map-rows: none, + map-cols: none, + ..items + ) = { + // ... + } + ``` + + **Parameters:** + + - `columns`: The sizes (widths) of each column. They work just like regular `table`'s columns, and can be: + - an array of lengths (`1pt`, `2em`, `100%`, ...), including fractional (`2fr`), to specify the width of each column + - For instance, `columns: (2pt, 3em)` will give you two columns: one with a width of `2pt` and another with the width of `3em` (3 times the font size). + - Note that percentages, such as `49%`, **are considered fixed widths** as they are **always multiplied by the full page width** (minus margins) for columns. Thus, a column with a size of `100%` would span your whole page (even if there are other columns). + - `auto` may be specified to automatically resize the column based on the largest width of its contents, if possible - **this is the most common column width choice,** as it just delegates the column sizing job to tablex! + - For example, if your `auto`-sized column contains two cells with `Hello world!` and `Bye!` as contents, tablex will try to make the column large enough for `Hello world!` (the cell with largest _potential_ width) to fit in a single line. + - However, note that often enough that's not possible, as increasing the column's size too much would result in the table going over the page's margin - perhaps even beyond the document's total width. Therefore, **tablex will automatically reduce the size of your `auto` columns** when they would otherwise cause the table to overrun the page's normal width (i.e. the width between the page's lateral margins). + - Fixed width columns (such as `2pt`, `3em` or `49%`) are not subject to this size reduction; thus, if you specify all columns' widths with fixed lengths, your table _could_ become larger than the page's width! (In such a case, **`auto` columns would be reduced to a size of zero,** as there would be no available space anymore!) + - when specifying fractional widths (`1fr`, `2fr`...) for columns, the available space (remaining page width, after calculating all other columns' sizes) is divided between them, weighted on the fraction value of each column. + - For example, with `(1fr, 2fr)`, the available space will be divided by 3 (1 + 2), and the first column will have 1/3 of the space, while the second will have 2/3. + - `(1fr, 1fr)` would cause both columns to have equal length (1/2 and 1/2 of the available space). + - This is useful when you want some columns to just occupy all the remaining horizontal space in the page. + - **Note:** If only one column has a fractional width (e.g. a single column with `1fr`), it will occupy the entire available space. + - **Warning:** fractional columns in tablex (much like in Typst's default tables) **will not work properly in pages with `auto` width** (the columns will have width zero) - this is because those pages theoretically have infinite width (they can expand indefinitely), so having columns spanning the entire available width is then impossible! + - a single length like above, to indicate the width of a single column (equivalent to just placing it inside a unit array) + - For instance, `columns: 2pt` is equivalent to `columns: (2pt,)`, which translates to a single column of width `2pt`. + - an integer (such as `4`), as a shorthand for `(auto,) * 4` (that many `auto` columns) + - Useful if you just want to quickly set the amount of columns without worrying about their sizes (`columns: 4` will give you four `auto` columns). + - `rows`: The sizes (heights) of each row. They follow the exact same format as `columns`, except that the "available space" is infinite (auto rows can expand as much as is needed, as the table can add rows over multiple pages). + - **Note:** For rows, percentages (such as `49%`) are fixed width lengths, like in `columns`; however, here, they are **multiplied by the page's full height** (minus margins), and not width. + - **Note:** If more rows than specified are added, the height for the **last row** will be the one assigned to all extra rows. (If the last row is `auto`, the extra ones will also be `auto`, for example.) + - Your table can have more rows than expected by simply having more cells than `(# columns)` multipled by `(# rows)`. In this case, you will have an extra row for each `(# columns)` cells after the limit. In other words, **the amount of columns is always fixed** (determined by the amount of widths in the array given to `columns`), but the amount of rows can vary depending on your input of cells to the table. + - Adding a cell at an arbitrary `y` coordinate can also cause your table to have extra rows (enough rows to reach the cell at that coordinate). + - **Warning:** support for fractional sizes for rows is still rudimentary - they only work properly on the table's first page; on the second page and onwards, they will not behave properly, differently from the default `#table`. + - `inset`: Inset/internal padding to give to each cell. Can be either a length (same inset from the top, bottom, left and right of the cell), or a dictionary (e.g. `(left: 5pt, right: 10pt, bottom: 2pt, top: 4pt)`, or even `(left: 5pt, rest: 10pt)` to apply the same value to the remaining sides). Defaults to `5pt` (the `#table` default). + + - `align`: How to align text in the cells. Defaults to `auto`, which inherits alignment from the outer context. Must be either `auto`, an `alignment` (such as `left` or `top`), a `2d alignment` (such as `left + top`), an `array` of alignment/2d alignment (one for each column in the table - if there are more columns than alignment values, they will alternate); or a function `(column, row) => alignment/2d alignment` (to customize for each individual cell). + + - `fill`: Color with which to fill cells' backgrounds. Defaults to `none`, or no fill. Must be either a `color`, such as `blue`; an `array` of colors (one for each column in the table - if there are more columns than colors, they will alternate); or a function `(column, row) => color` (to customize for each individual cell). + + - `stroke`: Indicates how to draw the table lines. Defaults to the current line styles in the document. For example: `5pt + red` to change the color and the thickness. + + - `column-gutter`: optional separation (length) between columns (such as `5pt`). Defaults to `none` (disable). At the moment, looks a bit ugly if your table has a `hline` attempting to cross a `colspan`. + + - `row-gutter`: optional separation (length) between rows. Defaults to `none` (disable). At the moment, looks a bit ugly if your table has a `vline` attempting to cross a `rowspan`. + + - `gutter`: Sets a length to both `column-` and `row-gutter` at the same time (overridable by each). + + - `repeat-header`: Controls header repetition. If set to `true`, the first row (or the amount of rows specified in `header-rows`), including its rowspans, is repeated across all pages this table spans. If set to `false` (default), the aforementioned header row is not repeated in any page. If set to an integer (such as `4`), repeats for that many pages after the first, then stops. If set to an array of integers (such as `(3, 4)`), repeats only on those pages _relative to the table's first page_ (page 1 here is where the table is, so adding `1` to said array has no effect). + + - `header-rows`: minimum amount of rows for the repeatable + header. 1 by default. Automatically increases if + one of the cells is a rowspan that would go beyond the + given amount of rows. For example, if 3 is given, + then at least the first 3 rows will repeat. + + - `header-hlines-have-priority`: if `true`, the horizontal + lines below the header being repeated take priority + over the rows they appear atop of on further pages. + If `false`, they draw their own horizontal lines. + Defaults to `true`. + - For example, if your header has a blue hline under it, that blue hline will display on all pages it is repeated on if this option is `true`. If this option is `false`, the header will repeat, but the blue hline will not. + + - `rtl`: if true, the table is horizontally flipped. That is, cells and lines are placed in the opposite order (starting from the right), and horizontal lines are flipped. + This is meant to simulate the behavior of default Typst tables when `set text(dir: rtl)` is used, + and is useful when writing in a language with a RTL (right-to-left) script. + Defaults to `false`. + + - `auto-lines`: Shorthand to apply a boolean to both `auto-hlines` and `auto-vlines` at the same time (overridable by each). Defaults to `true`. + + - `auto-hlines`: If `true`, draw a horizontal line on every line where you did not manually draw one; if `false`, no hlines other than the ones you specify (via `hlinex`) are drawn. Defaults to `auto` (follows `auto-lines`, which in turn defaults to `true`). + + - `auto-vlines`: If `true`, draw a vertical line on every line where you did not manually draw one; if `false`, no vlines other than the ones you specify (via `vlinex`) are drawn. Defaults to `auto` (follows `auto-lines`, which in turn defaults to `true`). + + - `map-cells`: A function which takes a single `cellx` and returns another `cellx`, or a `content` which is converted to `cellx` by `cellx[#content]`. You can customize the cell in pretty much any way using this function; just take care to avoid conflicting with already-placed cells if you move it. + + - `map-hlines`: A function which takes each horizontal line object (`hlinex`) and returns another, optionally modifying its properties. You may also change its row position (`y`). Note that this is also applied to lines generated by `auto-hlines`. + + - `map-vlines`: A function which takes each horizontal line object (`vlinex`) and returns another, optionally modifying its properties. You may also change its column position (`x`). Note that this is also applied to lines generated by `auto-vlines`. + + - `map-rows`: A function mapping each row of cells to new values or modified properties. + Takes `(row_num, cell_array)` and returns + the modified `cell_array`. Note that, with your function, they + cannot be sent to another row. Also, please preserve the order of the cells. This is especially important given that cells may be `none` if they're actually a position taken by another cell with colspan/rowspan. Make sure the `none` values are in the same indexes when the array is returned. + + - `map-cols`: A function mapping each column of cells to new values or modified properties. + Takes `(col_num, cell_array)` and returns + the modified `cell_array`. Note that, with your function, they + cannot be sent to another column. Also, please preserve the order of the cells. This is especially important given that cells may be `none` if they're actually a position taken by another cell with colspan/rowspan. Make sure the `none` values are in the same indexes when the array is returned. + + - `fit-spans`: either a dictionary `(x: bool, y: bool)` or just `bool` (e.g. just `true` is converted to `(x: true, y: true)`). When given `(x: true)`, colspans won't affect the sizes of `auto` columns. When given `(y: true)`, rowspans won't affect the sizes of `auto` rows. By default, this is equal to `(x: false, y: false)` (equivalent to just `false`), which means that colspans will cause the last spanned `auto` column to expand (depending on the contents of the cell) and rowspans will cause the last spanned `auto` row to expand similarly. + - This is usually used as `(x: true)` to prevent unexpected expansion of `auto` columns after using a colspan, which can happen when a colspan spans both a fractional-size column (e.g. `1fr`) and an `auto`-sized column. Can be applied to rows too through `(y: true)` or `(x: true, y: true)`, if needed, however. + - The point of this option is to have colspans and rowspans not affect the size of the table at all, and just "fit" within the columns and rows they span. Therefore, this option does not have any effect upon colspans and rowspans which don't span columns or rows with automatic size. + +## Changelog + +### v0.0.8 + +- Added `fit-spans` option to `tablex` and `cellx` (https://github.com/PgBiel/typst-tablex/pull/111) + - Accepts `(x: bool, y: bool)`. When set to `(x: true)`, colspans won't affect the sizes of `auto` columns. When set to `(y: true)`, rowspans won't affect the sizes of `auto` rows. + - Defaults to `false`, equivalent to `(x: false, y: false)`, that is, colspans and rowspans affect the sizes of `auto` tracks (columns and rows) by default (expanding the last spanned track if the colspan/rowspan is too large). + - Useful when you want merged cells (or a specific merged cell) to "fit" within their spanned columns and rows. May help when adding a colspan or rowspan causes an `auto`-sized track to inadvertently expand. +- `auto` column sizing received multiple improvements and bug fixes. Tables should now have more natural column widths. (https://github.com/PgBiel/typst-tablex/pull/109, https://github.com/PgBiel/typst-tablex/pull/116) + - Fixes some problems with overflowing cells (https://github.com/PgBiel/typst-tablex/issues/48, https://github.com/PgBiel/typst-tablex/issues/75) + - Fixes `auto` columns being needlessly expanded in some cases (https://github.com/PgBiel/typst-tablex/issues/56, https://github.com/PgBiel/typst-tablex/issues/78) + - For similar problems not fixed by this, please use the new `fit-spans` option as needed, or use fixed-size columns instead. +- Several performance optimizations and other internal code improvements were made (https://github.com/PgBiel/typst-tablex/pull/113, https://github.com/PgBiel/typst-tablex/pull/114, https://github.com/PgBiel/typst-tablex/pull/115). + - Documents with lots of `tablex` tables might now become **up to 20% faster** to cold compile. Give it a shot! +- Fixed extra fixed-height rows appearing to have `auto` height (https://github.com/PgBiel/typst-tablex/pull/108). +- Fixed rows without any visible cells being drawn with zero height (https://github.com/PgBiel/typst-tablex/pull/107). + - Fixes some rowspans causing cells to overlap (https://github.com/PgBiel/typst-tablex/issues/82, https://github.com/PgBiel/typst-tablex/issues/105). + +### v0.0.7 + +I have begun [work on bringing many tablex improvements to built-in Typst tables](https://github.com/PgBiel/typst-improv-tables-planning)! In that regard, [you can now sponsor my work on tablex and improving Typst tables via GitHub Sponsors! Consider taking a look :)](https://github.com/sponsors/PgBiel) + +- Allow gradients and patterns in fills (https://github.com/PgBiel/typst-tablex/pull/87) +- Fixed a critical bug where `line` in tablex cells would misbehave (https://github.com/PgBiel/typst-tablex/issues/80) + - CeTZ and drawing in general should now work properly within tablex cells (see https://github.com/johannes-wolf/cetz/issues/345). + - Also fixes a problem with nested tables (https://github.com/PgBiel/typst-tablex/issues/34) +- Fixed negative line expansion within a single cell (https://github.com/PgBiel/typst-tablex/pull/84) + - Negative line expansion across multiple cells isn't yet supported. + - Thanks GitHub user @dixslyf for the great work on fixing and testing this! +- Made internal length calculation procedures more robust (https://github.com/PgBiel/typst-tablex/issues/92, https://github.com/PgBiel/typst-tablex/issues/94) + - Fixes a potential incompatibility with (currently unreleased) Typst 0.11.0 +- Added missing support for boolean types in Typst 0.8.0+ (https://github.com/PgBiel/typst-tablex/issues/73) +- Added some keywords to tablex's `typst.toml` for better discoverability (https://github.com/PgBiel/typst-tablex/issues/91) + +### v0.0.6 + +- Added support for RTL tables with `rtl: true` (https://github.com/PgBiel/typst-tablex/issues/58). + - Default Typst tables are automatically flipped horizontally when using `set text(dir: rtl)`, however we can't detect that setting from tablex at this moment (it isn't currently possible to fetch set rules in Typst). + - Therefore, as a way around that, you can now specify `#tablex(rtl: true, ...)` to flip your table horizontally if you're writing a document in RTL (right-to-left) script. (You can use e.g. `#let old-tablex = tablex` followed by `#let tablex(..args) = old-tablex(rtl: true, ..args)` to not have to repeat the `rtl` parameter every time.) +- Added support for `box`'s dictionary inset syntax on tablex (https://github.com/PgBiel/typst-tablex/issues/54). + - For instance, you can now do `#tablex(inset: (left: 5pt, top: 10pt, rest: 2pt), ...)`. +- Fixed errors when using floating point strokes or other more complex strokes (https://github.com/PgBiel/typst-tablex/issues/55). +- Added full compatibility with the new Typst 0.8.0 type system (https://github.com/PgBiel/typst-tablex/issues/69). +- Added info about `#rotate` problems to "Known Issues" in the README (https://github.com/PgBiel/typst-tablex/pull/60). +- Improved docs for tablex options `columns` and `rows` (https://github.com/PgBiel/typst-tablex/issues/53). + +### v0.0.5 + +- ⚠️ **Minimum Typst version raised to v0.2.0** +- Improved calculation of page/container dimensions by using the `layout()` function. + - Fixes tables with fractional columns not displaying properly in blocks with `auto` width (https://github.com/PgBiel/typst-tablex/issues/44; https://github.com/PgBiel/typst-tablex/issues/39) + - Fixes some nested tables overflowing the page width (https://github.com/PgBiel/typst-tablex/issues/41) + - Fixes bad interaction between tables with fractional columns and nested tables (https://github.com/PgBiel/typst-tablex/issues/28) + - Fixes table rotation messing up table size calculation (https://github.com/PgBiel/typst-tablex/issues/52) + - Probably fixes other issues not listed here as well. +- Added some guards for infinite lengths and `auto`-sized pages (https://github.com/PgBiel/typst-tablex/issues/47). +- Fixed tablex crashes/improper behavior with `em` strokes and other types of strokes (https://github.com/PgBiel/typst-tablex/issues/49). +- Added the tablex version number as a comment in the source file (as requested in https://github.com/PgBiel/typst-tablex/issues/25). + +### v0.0.4 + +- Added `typst.toml` to support Typst v0.6.0's soon-to-be-released package manager (see https://github.com/PgBiel/typst-tablex/issues/22). +- Fixed a division by zero regression from v0.0.3 (https://github.com/PgBiel/typst-tablex/issues/19). +- Fixed a bug where cells placed in arbitrary positions could force an extra empty row to appear (https://github.com/PgBiel/typst-tablex/issues/16). +- Fixed `hlinex(gutter-restrict: top)` causing the hline to just disappear (https://github.com/PgBiel/typst-tablex/issues/20). +- Fixed certain `gutter-restrict` lines disappearing when there's no gutter (https://github.com/PgBiel/typst-tablex/issues/21). +- Fixed row gutter lines not properly splitting across pages (https://github.com/PgBiel/typst-tablex/issues/23). + +### v0.0.3 + +- Added support for Typst v0.4.0 and v0.5.0. + - The tablex options `fill:` and `align:` now accept arrays of values for each column (https://github.com/PgBiel/typst-tablex/issues/13). + - For example, `fill: (red, blue)` would fill the first column with red, the second column with blue, and any further columns would alternate between the two fill colors. +- Fixed the calculation of the size of `auto` rows and columns when a rowspan or colspan was used (https://github.com/PgBiel/typst-tablex/issues/11). +- Fixed the calculation of the size of the last `auto` column when it was too long (https://github.com/PgBiel/typst-tablex/issues/6). + +### v0.0.2 + +- Added support for Typst v0.3.0. +- Fixed strokes - now lines will expand to not look weird when strokes are larger. + - You can disable this behavior by setting `stroke-expand: false` on your lines. +- You can now arbitrarily change your lines' sizes at either end with the option `expand: (length, length)`; e.g. `expand: (5pt, 10pt)` will increase your horizontal line 5pt to the left and 10pt to the right (or, for a vertical line, 5pt to the top and 10pt to the bottom). + - Support for negative expand lengths is limited (so far, only reduces length in the first cell the line spans). +- Added some gutter fixes (not all gutter issues were fixed yet). + +### v0.0.1 + +Initial release. + +- Added types `tablex`, `cellx`, `hlinex`, `vlinex` +- Added type aliases `gridx`, `rowspanx`, `colspanx` + +## 0.1.0 Roadmap + +- [ ] General + - [X] More docs + - [ ] Code cleanup + - [ ] Table drawing rework +- [ ] `#table` parity + - [X] `columns:`, `rows:` + - [X] Basic support + - [X] Accept a single size to mean a single column + - [X] Adjust `auto` columns and rows + - [X] Accept integers to mean multiple `auto` + - [X] Basic unit conversion (em -> pt, etc.) + - [X] Ratio unit conversion (100% -> page width...) + - [X] Fractional unit conversion based on available space (1fr, 2fr -> 1/3, 2/3) + - [X] Shrink `auto` columns based on available space + - [X] `fill` + - [X] Basic support (`color` for general fill) + - [X] Accept a function (`(column, row) => color`) + - [X] Accept an array of colors (one for each column) + - [X] `align` + - [X] Basic support (`alignment` and `2d alignment` apply to all cells) + - [X] Accept a function (`(column, row) => alignment/2d alignment`) + - [X] Accept an array of alignment values (one for each column) + - [X] `inset` + - [ ] `gutter` + - [X] Basic support + - [X] `column-gutter` + - [X] `row-gutter` + - [ ] Hline, vline adaptations + - [X] `stop-pre-gutter`: Makes the hline/vline not transpose gutter boundaries + - [X] `gutter-restrict`: Makes the hline/vline not draw on both sides of a gutter boundary, and instead pick one (top/bottom; left/right) + - [ ] Properly work with gutters after colspanxs/rowspanxs + - [X] `stroke` + - [X] Basic support (change all lines, vline or hline, without override) + - [X] `none` for no stroke + - [X] Default to lines on every row and column +- [ ] New features for `#tablex` + - [X] Basic types (`cellx`, `hlinex`, `vlinex`) + - [X] `hlinex`, `vlinex` + - [X] Auto-positioning when placed among cells + - [X] Arbitrary positioning + - [X] Allow customizing `stroke` + - [X] `colspanx`, `rowspanx` + - [X] Interrupt `hlinex` and `vlinex` with `end: auto` + - [X] Support simultaneous col/rowspan with `cellx(colspanx:, rowspanx:)` + - [X] Support nesting colspan/rowspan (`colspanx(rowspanx())`) + - [X] Support cell attributes (e.g. `colspanx(2, align: left)[a]`) + - [X] Reliably detect conflicts + - [ ] Repeating headers + - [X] Basic support (first row group repeats on every page) + - [ ] Work with different page sizes + - [X] `repeat-header`: Control header repetition + - [X] `true`: Repeat on all pages + - [X] integer: Repeat for the next 'n' pages + - [X] array of integers: Repeat on those (relative) pages + - [X] `false` (default): Do not repeat + - [X] `header-rows`: Indicate what to consider as a "header" + - [X] integer: At least first 'n' rows are a header (plus whatever rowspanxs show up there) + - [X] Defaults to 1 + - [X] `none` or `0`: no header (disables header repetition regardless of `repeat-header`) + - [X] `cellx` + - [X] Auto-positioning based on order and columns + - [X] Place empty cells when there are too many + - [X] Allow arbitrary positioning with `cellx(x:, y:)` + - [X] Allow `align` override + - [X] Allow `fill` override + - [X] Allow `inset` override + - [X] Works properly only with `auto` cols/rows + - [X] Dynamic content (maybe shortcut for `map-cells` on a single cell) + - [X] Auto-lines + - [X] `auto-hlines` - `true` to place on all lines without hlines, `false` otherwise + - [X] `auto-vlines` - similar + - [X] `auto-lines` - controls both simultaneously (defaults to `true`) + - [X] Iteration attributes + - [X] `map-cells` - Customize every single cell + - [X] `map-hlines` - Customize each horizontal line + - [X] `map-vlines` - Customize each vertical line + - [X] `map-rows` - Customize entire rows of cells + - [X] `map-cols` - Customize entire columns of cells + +## License + +MIT license (see the `LICENSE` file). diff --git a/packages/preview/tablex/0.0.8/tablex.typ b/packages/preview/tablex/0.0.8/tablex.typ new file mode 100644 index 000000000..dbcf6fcc4 --- /dev/null +++ b/packages/preview/tablex/0.0.8/tablex.typ @@ -0,0 +1,2971 @@ +// Welcome to tablex! +// Feel free to contribute with any features you think are missing. +// Version: v0.0.8 + +// -- table counter -- + +#let _tablex-table-counter = counter("_tablex-table-counter") + +// -- compat -- + +// get the types of things so we can compare with them +// (0.2.0-0.7.0: they're strings; 0.8.0+: they're proper types) +#let _array-type = type(()) +#let _dict-type = type((a: 5)) +#let _bool-type = type(true) +#let _str-type = type("") +#let _color-type = type(red) +#let _stroke-type = type(red + 5pt) +#let _length-type = type(5pt) +#let _rel-len-type = type(100% + 5pt) +#let _ratio-type = type(100%) +#let _int-type = type(5) +#let _float-type = type(5.0) +#let _fraction-type = type(5fr) +#let _function-type = type(x => x) +#let _content-type = type([]) +// note: since 0.8.0, alignment and 2d alignment are the same +// but keep it like this for pre-0.8.0 +#let _align-type = type(left) +#let _2d-align-type = type(top + left) + +// If types aren't strings, this means we're using 0.8.0+. +#let using-typst-v080-or-later = str(type(_str-type)) == "type" + +// Attachments use "t" and "b" instead of "top" and "bottom" since v0.3.0. +#let using-typst-v030-or-later = using-typst-v080-or-later or $a^b$.body.has("t") + +// This is true if types have fields in the current Typst version. +// This means we can use stroke.thickness, length.em, and so on. +#let typst-fields-supported = using-typst-v080-or-later + +// This is true if calc.rem exists in the current Typst version. +// Otherwise, we use a polyfill. +#let typst-calc-rem-supported = using-typst-v030-or-later + +// Remainder operation. +#let calc-mod = if typst-calc-rem-supported { + calc.rem +} else { + (a, b) => calc.floor(a) - calc.floor(b * calc.floor(a / b)) +} + +// Returns the sign of the operand. +// -1 for negative, 1 for positive or zero. +#let calc-sign(x) = { + // For positive: true - false = 1 - 0 = 1 + // For zero: true - false = 1 - 0 = 1 + // For negative: false - true = 0 - 1 = -1 + int(0 <= x) - int(x < 0) +} + +// Polyfill for array sum (.sum() is Typst 0.3.0+). +#let array-sum(arr, zero: 0) = { + arr.fold(zero, (a, x) => a + x) +} + +// -- common validators -- + +// Converts the 'fit-spans' argument to a (x: bool, y: bool) dictionary. +// Optionally use a default dictionary to fill missing arguments with. +// This is in the common section as it is needed by the grid section as well. +#let validate-fit-spans(fit-spans, default: (x: false, y: false), error-prefix: none) = { + if type(error-prefix) == _str-type { + error-prefix = " " + error-prefix + } else { + error-prefix = "" + } + if type(fit-spans) == _bool-type { + fit-spans = (x: fit-spans, y: fit-spans) + } + if type(fit-spans) == _dict-type { + assert(fit-spans.len() > 0, message: "Tablex error:" + error-prefix + " 'fit-spans', if a dictionary, must not be empty.") + assert(fit-spans.keys().all(k => k in ("x", "y")), message: "Tablex error:" + error-prefix + " 'fit-spans', if a dictionary, must only have the keys x and y.") + assert(fit-spans.values().all(v => type(v) == _bool-type), message: "Tablex error:" + error-prefix + " keys 'x' and 'y' in the 'fit-spans' dictionary must be booleans (true/false).") + for key in ("x", "y") { + if key in default and key not in fit-spans { + fit-spans.insert(key, default.at(key)) + } + } + } else { + panic("Tablex error:" + error-prefix + " Expected 'fit-spans' to be either a boolean or dictionary, found '" + str(type(fit-spans)) + "'") + } + fit-spans +} + +// ------------ + +// -- types -- + +#let hlinex( + start: 0, end: auto, y: auto, + stroke: auto, + stop-pre-gutter: auto, gutter-restrict: none, + stroke-expand: true, + expand: none +) = ( + tablex-dict-type: "hline", + start: start, + end: end, + y: y, + stroke: stroke, + stop-pre-gutter: stop-pre-gutter, + gutter-restrict: gutter-restrict, + stroke-expand: stroke-expand, + expand: expand, + parent: none, // if hline was broken into multiple +) + +#let vlinex( + start: 0, end: auto, x: auto, + stroke: auto, + stop-pre-gutter: auto, gutter-restrict: none, + stroke-expand: true, + expand: none +) = ( + tablex-dict-type: "vline", + start: start, + end: end, + x: x, + stroke: stroke, + stop-pre-gutter: stop-pre-gutter, + gutter-restrict: gutter-restrict, + stroke-expand: stroke-expand, + expand: expand, + parent: none, +) + +#let cellx(content, + x: auto, y: auto, + rowspan: 1, colspan: 1, + fill: auto, align: auto, + inset: auto, + fit-spans: auto +) = ( + tablex-dict-type: "cell", + content: content, + rowspan: rowspan, + colspan: colspan, + align: align, + fill: fill, + inset: inset, + fit-spans: fit-spans, + x: x, + y: y, +) + +#let occupied(x: 0, y: 0, parent_x: none, parent_y: none) = ( + tablex-dict-type: "occupied", + x: x, + y: y, + parent_x: parent_x, + parent_y: parent_y +) + +// -- end: types -- + +// -- type checks, transformers and validators -- + +// Is this a valid dict created by this library? +#let is-tablex-dict(x) = ( + type(x) == _dict-type + and "tablex-dict-type" in x +) + +#let is-tablex-dict-type(x, ..dict_types) = ( + is-tablex-dict(x) + and x.tablex-dict-type in dict_types.pos() +) + +#let is-tablex-cell(x) = is-tablex-dict-type(x, "cell") +#let is-tablex-hline(x) = is-tablex-dict-type(x, "hline") +#let is-tablex-vline(x) = is-tablex-dict-type(x, "vline") +#let is-some-tablex-line(x) = is-tablex-dict-type(x, "hline", "vline") +#let is-tablex-occupied(x) = is-tablex-dict-type(x, "occupied") + +#let table-item-convert(item, keep_empty: true) = { + if type(item) == _function-type { // dynamic cell content + cellx(item) + } else if keep_empty and item == () { + item + } else if type(item) != _dict-type or "tablex-dict-type" not in item { + cellx[#item] + } else { + item + } +} + +#let rowspanx(length, content, ..cell_options) = { + if is-tablex-cell(content) { + (..content, rowspan: length, ..cell_options.named()) + } else { + cellx( + content, + rowspan: length, + ..cell_options.named()) + } +} + +#let colspanx(length, content, ..cell_options) = { + if is-tablex-cell(content) { + (..content, colspan: length, ..cell_options.named()) + } else { + cellx( + content, + colspan: length, + ..cell_options.named()) + } +} + +// Get expected amount of cell positions +// in the table (considering colspan and rowspan) +#let get-expected-grid-len(items, col_len: 0) = { + let len = 0 + + // maximum explicit 'y' specified + let max_explicit_y = items + .filter(c => c.y != auto) + .fold(0, (acc, cell) => { + if (is-tablex-cell(cell) + and type(cell.y) in (_int-type, _float-type) + and cell.y > acc) { + cell.y + } else { + acc + } + }) + + for item in items { + if is-tablex-cell(item) and item.x == auto and item.y == auto { + // cell occupies (colspan * rowspan) spaces + len += item.colspan * item.rowspan + } else if type(item) == _content-type { + len += 1 + } + } + + let rows(len) = calc.ceil(len / col_len) + + while rows(len) < max_explicit_y { + len += col_len + } + + len +} + +// Check if this length is infinite. +#let is-infinite-len(len) = { + type(len) in (_ratio-type, _fraction-type, _rel-len-type, _length-type) and "inf" in repr(len) +} + +// Check if this is a valid color (color, gradient or pattern). +#let is-color(val) = { + type(val) == _color-type or str(type(val)) in ("gradient", "pattern") +} + +#let validate-cols-rows(columns, rows, items: ()) = { + if type(columns) == _int-type { + assert(columns >= 0, message: "Error: Cannot have a negative amount of columns.") + + columns = (auto,) * columns + } + + if type(rows) == _int-type { + assert(rows >= 0, message: "Error: Cannot have a negative amount of rows.") + rows = (auto,) * rows + } + + if type(columns) != _array-type { + columns = (columns,) + } + + if type(rows) != _array-type { + rows = (rows,) + } + + // default empty column to a single auto column + if columns.len() == 0 { + columns = (auto,) + } + + // default empty row to a single auto row + if rows.len() == 0 { + rows = (auto,) + } + + let col_row_is_valid(col_row) = ( + (not is-infinite-len(col_row)) and (col_row == auto or type(col_row) in ( + _fraction-type, _length-type, _rel-len-type, _ratio-type + )) + ) + + if not columns.all(col_row_is_valid) { + panic("Invalid column sizes (must all be 'auto' or a valid, finite length specifier).") + } + + if not rows.all(col_row_is_valid) { + panic("Invalid row sizes (must all be 'auto' or a valid, finite length specifier).") + } + + let col_len = columns.len() + + let grid_len = get-expected-grid-len(items, col_len: col_len) + + let expected_rows = calc.ceil(grid_len / col_len) + + // more cells than expected => add rows + if rows.len() < expected_rows { + let missing_rows = expected_rows - rows.len() + + rows += (rows.last(),) * missing_rows + } + + (columns: columns, rows: rows, items: ()) +} + +// -- end: type checks and validators -- + +// -- utility functions -- + +// Which positions does a cell occupy +// (Usually just its own, but increases if colspan / rowspan +// is greater than 1) +#let positions-spanned-by(cell, x: 0, y: 0, x_limit: 0, y_limit: none) = { + let result = () + let rowspan = if "rowspan" in cell { cell.rowspan } else { 1 } + let colspan = if "colspan" in cell { cell.colspan } else { 1 } + + if rowspan < 1 { + panic("Cell rowspan must be 1 or greater (bad cell: " + repr((x, y)) + ")") + } else if colspan < 1 { + panic("Cell colspan must be 1 or greater (bad cell: " + repr((x, y)) + ")") + } + + let max_x = x + colspan + let max_y = y + rowspan + + if x_limit != none { + max_x = calc.min(x_limit, max_x) + } + + if y_limit != none { + max_y = calc.min(y_limit, max_y) + } + + for x in range(x, max_x) { + for y in range(y, max_y) { + result.push((x, y)) + } + } + + result +} + +// initialize an array with a certain element or init function, repeated +#let init-array(amount, element: none, init_function: none) = { + let nones = () + + if init_function == none { + init_function = () => element + } + + range(amount).map(i => init_function()) +} + +// Default 'x' to a certain value if it is equal to the forbidden value +// ('none' by default) +#let default-if-not(x, default, if_isnt: none) = { + if x == if_isnt { + default + } else { + x + } +} + +// Default 'x' to a certain value if it is none +#let default-if-none(x, default) = default-if-not(x, default, if_isnt: none) + +// Default 'x' to a certain value if it is auto +#let default-if-auto(x, default) = default-if-not(x, default, if_isnt: auto) + +// Default 'x' to a certain value if it is auto or none +#let default-if-auto-or-none(x, default) = if x in (auto, none) { + default +} else { + x +} + +// The max between a, b, or the other one if either is 'none'. +#let max-if-not-none(a, b) = if a in (none, auto) { + b +} else if b in (none, auto) { + a +} else { + calc.max(a, b) +} + +// Gets the topmost parent of a line. +#let get-top-parent(line) = { + let previous = none + let current = line + + while current != none { + previous = current + current = previous.parent + } + + previous +} + +// Typst 0.9.0 uses a minus sign ("−"; U+2212 MINUS SIGN) for negative numbers. +// Before that, it used a hyphen minus ("-"; U+002D HYPHEN MINUS), so we use +// regex alternation to match either of those. +#let NUMBER-REGEX-STRING = "(?:−|-)?\\d*\\.?\\d+" + +// Check if the given length has type '_length-type' and no 'em' component. +#let is-purely-pt-len(len) = { + type(len) == _length-type and "em" not in repr(len) +} + +// Measure a length in pt by drawing a line and using the measure() function. +// This function will work for negative lengths as well. +// +// Note that for ratios, the measurement will be 0pt due to limitations of +// the "draw and measure" technique (wrapping the line in a box still returns 0pt; +// not sure if there is any viable way to measure a ratio). This also affects +// relative lengths — this function will only be able to measure the length component. +// +// styles: from style() +#let measure-pt(len, styles) = { + if typst-fields-supported { + // We can use fields to separate em from pt. + let pt = len.abs + let em = len.em + // Measure with abs (and later multiply by the sign) so negative em works. + // Otherwise it would return 0pt, and we would need to measure again with abs. + let measured-em = calc-sign(em) * measure(box(width: calc.abs(em) * 1em), styles).width + + return pt + measured-em + } + + // Fields not supported, so we have to measure twice when em can be negative. + let measured-pt = measure(box(width: len), styles).width + + // If the measured length is positive, `len` must have overall been positive. + // There's nothing else to be done, so return the measured length. + if measured-pt > 0pt { + return measured-pt + } + + // If we've reached this point, the previously measured length must have been `0pt` + // (drawing a line with a negative length will draw nothing, so measuring it will return `0pt`). + // Hence, `len` must either be `0pt` or negative. + // We multiply `len` by -1 to get a positive length, draw a line and measure it, then negate + // the measured length. This nicely handles the `0pt` case as well. + measured-pt = -measure(box(width: -len), styles).width + return measured-pt +} + +// Convert a length of type length to pt. +// +// styles: from style() +#let convert-length-type-to-pt(len, styles: none) = { + // repr examples: "1pt", "1em", "0.5pt", "0.5em", "1pt + 1em", "-0.5pt + -0.5em" + if is-purely-pt-len(len) { + // No need to do any conversion because it must already be in pt. + return len + } + + // At this point, we will need to draw a line for measurement, + // so we need the styles. + if styles == none { + panic("Cannot convert length to pt ('styles' not specified).") + } + + return measure-pt(len, styles) +} + +// Convert a ratio type length to pt +// +// page-size: equivalent to 100% +#let convert-ratio-type-to-pt(len, page-size) = { + assert( + is-purely-pt-len(page-size), + message: "'page-size' should be a purely pt length" + ) + + if page-size == none { + panic("Cannot convert ratio to pt ('page-size' not specified).") + } + + if is-infinite-len(page-size) { + return 0pt // page has 'auto' size => % should return 0 + } + + ((len / 1%) / 100) * page-size + 0pt // e.g. 100% / 1% = 100; / 100 = 1; 1 * page-size +} + +// Convert a fraction type length to pt +// +// frac-amount: amount of 'fr' specified +// frac-total: total space shared by fractions +#let convert-fraction-type-to-pt(len, frac-amount, frac-total) = { + assert( + is-purely-pt-len(frac-total), + message: "'frac-total' should be a purely pt length" + ) + + if frac-amount == none { + panic("Cannot convert fraction to pt ('frac-amount' not specified).") + } + + if frac-total == none { + panic("Cannot convert fraction to pt ('frac-total' not specified).") + } + + if frac-amount <= 0 or is-infinite-len(frac-total) { + return 0pt + } + + let len-per-frac = frac-total / frac-amount + + (len-per-frac * (len / 1fr)) + 0pt +} + +// Convert a relative type length to pt +// +// styles: from style() +// page-size: equivalent to 100% (optional because the length may not have a ratio component) +#let convert-relative-type-to-pt(len, styles, page-size: none) = { + if typst-fields-supported or eval(repr(0.00005em)) != 0.00005em { + // em repr changed in 0.11.0 => need to use fields here + // or use fields if they're supported anyway + return convert-ratio-type-to-pt(len.ratio, page-size) + convert-length-type-to-pt(len.length, styles: styles) + } + + // We will need to draw a line for measurement later, + // so we need the styles. + if styles == none { + panic("Cannot convert relative length to pt ('styles' not specified).") + } + + // Note on precision: the `repr` for em components is precise, unlike + // other length components, which are rounded to a precision of 2. + // This is true up to Typst 0.9.0 and possibly later versions. + let em-regex = regex(NUMBER-REGEX-STRING + "em") + let em-part-repr = repr(len).find(em-regex) + + // Calculate the length minus its em component. + // E.g., 1% + 1pt + 1em -> 1% + 1pt + let (em-part, len-minus-em) = if em-part-repr == none { + (0em, len) + } else { + // SAFETY: guaranteed to be a purely em length by regex + let em-part = eval(em-part-repr) + (em-part, len - em-part) + } + + // This will give only the pt part of the length. + // E.g., 1% + 1pt -> 1pt + // See the documentation on measure-pt for more information. + let pt-part = measure-pt(len-minus-em, styles) + + // Since we have the values of the em and pt components, + // we can calculate the ratio part. + let ratio-part = len-minus-em - pt-part + let ratio-part-pt = if ratio-part == 0% { + // No point doing `convert-ratio-type-to-pt` if there's no ratio component. + 0pt + } else { + convert-ratio-type-to-pt(ratio-part, page-size) + } + + // The length part is the pt part + em part. + // Note: we cannot use `len - ratio-part` as that returns a `_rel-len-type` value, + // not a `_length-type` value. + let length-part-pt = convert-length-type-to-pt(pt-part + em-part, styles: styles) + + ratio-part-pt + length-part-pt +} + +// Convert a certain (non-relative) length to pt +// +// styles: from style() +// page-size: equivalent to 100% +// frac-amount: amount of 'fr' specified +// frac-total: total space shared by fractions +#let convert-length-to-pt( + len, + styles: none, page-size: none, frac-amount: none, frac-total: none +) = { + page-size = 0pt + page-size + + if is-infinite-len(len) { + 0pt // avoid the destruction of the universe + } else if type(len) == _length-type { + convert-length-type-to-pt(len, styles: styles) + } else if type(len) == _ratio-type { + convert-ratio-type-to-pt(len, page-size) + } else if type(len) == _fraction-type { + convert-fraction-type-to-pt(len, frac-amount, frac-total) + } else if type(len) == _rel-len-type { + convert-relative-type-to-pt(len, styles, page-size: page-size) + } else { + panic("Cannot convert '" + type(len) + "' to length.") + } +} + +// Convert a stroke to its thickness +#let stroke-len(stroke, stroke-auto: 1pt, styles: none) = { + let no-ratio-error = "Tablex error: Stroke cannot be a ratio or relative length (i.e. have a percentage like '53%'). Try using the layout() function (or similar) to convert the percentage to 'pt' instead." + let stroke = default-if-auto(stroke, stroke-auto) + if type(stroke) == _length-type { + convert-length-to-pt(stroke, styles: styles) + } else if type(stroke) in (_rel-len-type, _ratio-type) { + panic(no-ratio-error) + } else if is-color(stroke) { + 1pt + } else if type(stroke) == _stroke-type { + if typst-fields-supported { + // No need for any repr() parsing, just use the thickness field. + let thickness = default-if-auto(stroke.thickness, 1pt) + return convert-length-to-pt(thickness, styles: styles) + } + + // support: + // - 2pt / 2em / 2cm / 2in + color + // - 2.5pt / 2.5em / ... + color + // - 2pt + 3em + color + let len-regex = "(?:" + NUMBER-REGEX-STRING + "(?:em|pt|cm|in|%)(?:\\s+\\+\\s+" + NUMBER-REGEX-STRING + "em)?)" + let r = regex("^" + len-regex) + let s = repr(stroke).find(r) + + if s == none { + // for more complex strokes, built through dictionaries + // => "thickness: 5pt" field + // note: on typst v0.7.0 or later, can just use 's.thickness' + let r = regex("thickness: (" + len-regex + ")") + s = repr(stroke).match(r) + if s != none { + s = s.captures.first(); // get the first match (the thickness) + } + } + + if s == none { + 1pt // okay it's probably just a color then + } else { + let len = eval(s) + if type(len) == _length-type { + convert-length-to-pt(len, styles: styles) + } else if type(len) in (_rel-len-type, _ratio-type) { + panic(no-ratio-error) + } else { + 1pt // should be unreachable + } + } + } else if type(stroke) == _dict-type and "thickness" in stroke { + let thickness = stroke.thickness + if type(thickness) == _length-type { + convert-length-to-pt(thickness, styles: styles) + } else if type(thickness) in (_rel-len-type, _ratio-type) { + panic(no-ratio-error) + } else { + 1pt + } + } else { + 1pt + } +} + +// --- end: utility functions --- + + +// --- grid functions --- + +#let create-grid(width, initial_height) = ( + tablex-dict-type: "grid", + items: init-array(width * initial_height), + width: width +) + +#let is-tablex-grid(value) = is-tablex-dict-type("grid") + +// Gets the index of (x, y) in a grid's array. +#let grid-index-at(x, y, grid: none, width: none) = { + width = default-if-none(grid, (width: width)).width + width = calc.floor(width) + (y * width) + calc-mod(x, width) +} + +// Gets the cell at the given grid x, y position. +// Width (amount of columns) per line must be known. +// E.g. grid-at(grid, 5, 2, width: 7) => 5th column, 2nd row (7 columns per row) +#let grid-at(grid, x, y) = { + let index = grid-index-at(x, y, width: grid.width) + + if index < grid.items.len() { + grid.items.at(index) + } else { + none + } +} + +// Returns 'true' if the cell at (x, y) +// exists in the grid. +#let grid-has-pos(grid, x, y) = ( + grid-index-at(x, y, grid: grid) < grid.items.len() +) + +// How many rows are in this grid? (Given its width) +#let grid-count-rows(grid) = ( + calc.floor(grid.items.len() / grid.width) +) + +// Converts a grid array index to (x, y) +#let grid-index-to-pos(grid, index) = ( + (calc-mod(index, grid.width), calc.floor(index / grid.width)) +) + +// Fetches an entire row of cells (all positions with the given y). +#let grid-get-row(grid, y) = { + let len = grid.items.len() + // position of the first cell in that row. + let first-row-pos = grid-index-at(0, y, grid: grid) + if len <= first-row-pos { + // grid isn't large enough, so no row to return + (none,) * grid.width + } else { + // position right after the last cell in this row + let next-row-pos = first-row-pos + grid.width + let cell-row = grid.items.slice(first-row-pos, calc.min(len, next-row-pos)) + let cell-row-len = cell-row.len() + if cell-row-len < grid.width { + // the row isn't complete because the grid wasn't large enough. + let missing-cells = (none,) * (grid.width - cell-row-len) + cell-row += missing-cells + } + cell-row + } +} + +// Fetches an entire column of cells (all positions with the given x). +#let grid-get-column(grid, x) = { + range(grid-count-rows(grid)).map(y => grid-at(grid, x, y)) +} + +// Expand grid to the given coords (add the missing cells) +#let grid-expand-to(grid, x, y, fill_with: (grid) => none) = { + let rows = grid-count-rows(grid) + let rowws = rows + + // quickly add missing rows + while rows < y { + grid.items += (fill_with(grid),) * grid.width + rows += 1 + } + + let now = grid-index-to-pos(grid, grid.items.len() - 1) + // now columns and/or last missing row + while not grid-has-pos(grid, x, y) { + grid.items.push(fill_with(grid)) + } + let new = grid-index-to-pos(grid, grid.items.len() - 1) + + grid +} + +// if occupied (extension of a cell) => get the cell that generated it. +// if a normal cell => return it, untouched. +#let get-parent-cell(cell, grid: none) = { + if is-tablex-occupied(cell) { + grid-at(grid, cell.parent_x, cell.parent_y) + } else if is-tablex-cell(cell) { + cell + } else { + panic("Cannot get parent table cell of a non-cell object: " + repr(cell)) + } +} + +// Return the next position available on the grid +#let next-available-position( + grid, x: 0, y: 0, x_limit: 0, y_limit: 0 +) = { + let cell = (x, y) + let there_is_next(cell_pos) = { + let grid_cell = grid-at(grid, ..cell_pos) + grid_cell != none + } + + while there_is_next(cell) { + x += 1 + + if x >= x_limit { + x = 0 + y += 1 + } + + cell = (x, y) + + if y >= y_limit { // last row reached - stop + break + } + } + + cell +} + +// Organize cells in a grid from the given items, +// and also get all given lines +#let generate-grid(items, x_limit: 0, y_limit: 0, map-cells: none, fit-spans: none) = { + // init grid as a matrix + // y_limit x x_limit + let grid = create-grid(x_limit, y_limit) + + let grid-index-at = grid-index-at.with(width: x_limit) + + let hlines = () + let vlines = () + + let prev_x = 0 + let prev_y = 0 + + let x = 0 + let y = 0 + + let first_cell_reached = false // if true, hline should always be placed after the current row + let row_wrapped = false // if true, a vline should be added to the end of a row + + let range_of_items = range(items.len()) + + let new_empty_cell(grid, index: auto) = { + let empty_cell = cellx[] + let index = default-if-auto(index, grid.items.len()) + let new_cell_pos = grid-index-to-pos(grid, index) + empty_cell.x = new_cell_pos.at(0) + empty_cell.y = new_cell_pos.at(1) + + empty_cell + } + + // go through all input + for i in range_of_items { + let item = items.at(i) + + // allow specifying () to change vline position + if type(item) == _array-type and item.len() == 0 { + if x == 0 and y == 0 { // increment vline's secondary counter + prev_x += 1 + } + + continue // ignore all '()' + } + + let item = table-item-convert(item) + + + if is-some-tablex-line(item) { // detect lines' x, y + if is-tablex-hline(item) { + let this_y = if first_cell_reached { + prev_y + 1 + } else { + prev_y + } + + item.y = default-if-auto(item.y, this_y) + + hlines.push(item) + } else if is-tablex-vline(item) { + if item.x == auto { + if x == 0 and y == 0 { // placed before any elements + item.x = prev_x + prev_x += 1 // use this as a 'secondary counter' + // in the meantime + + if prev_x > x_limit + 1 { + panic("Error: Specified way too many vlines or empty () cells before the first row of the table. (Note that () is used to separate vline()s at the beginning of the table.) Please specify at most " + str(x_limit + 1) + " empty cells or vlines before the first cell of the table.") + } + } else if row_wrapped { + item.x = x_limit // allow v_line at the last column + row_wrapped = false + } else { + item.x = x + } + } + + vlines.push(item) + } else { + panic("Invalid line received (must be hline or vline).") + } + items.at(i) = item // override item with the new x / y coord set + continue + } + + let cell = item + + assert(is-tablex-cell(cell), message: "All table items must be cells or lines.") + + first_cell_reached = true + + let this_x = default-if-auto(cell.x, x) + let this_y = default-if-auto(cell.y, y) + + if cell.x == none or cell.y == none { + panic("Error: Received cell with 'none' as x or y.") + } + + if this_x == none or this_y == none { + panic("Internal tablex error: Grid wasn't large enough to fit the given cells. (Previous position: " + repr((prev_x, prev_y)) + ", new cell: " + repr(cell) + ")") + } + + cell.x = this_x + cell.y = this_y + + if type(map-cells) == _function-type { + cell = table-item-convert(map-cells(cell)) + } + + assert(is-tablex-cell(cell), message: "Tablex error: 'map-cells' returned something that isn't a valid cell.") + + if row_wrapped { + row_wrapped = false + } + + let content = cell.content + let content = if type(content) == _function-type { + let res = content(this_x, this_y) + if is-tablex-cell(res) { + cell = res + this_x = cell.x + this_y = cell.y + [#res.content] + } else { + [#res] + } + } else { + [#content] + } + + if this_x == none or this_y == none { + panic("Error: Cell with function as content returned another cell with 'none' as x or y!") + } + + if type(this_x) != _int-type or type(this_y) != _int-type { + panic("Error: Cell coordinates must be integers. Invalid pair: " + repr((this_x, this_y))) + } + + cell.content = content + + // resolve 'fit-spans' option for this cell + if "fit-spans" not in cell { + cell.fit-spans = auto + } else if cell.fit-spans != auto { + cell.fit-spans = validate-fit-spans(cell.fit-spans, default: fit-spans, error-prefix: "At cell (" + str(this_x) + ", " + str(this_y) + "):") + } + + // up to which 'y' does this cell go + let max_x = this_x + cell.colspan - 1 + let max_y = this_y + cell.rowspan - 1 + + if this_x >= x_limit { + panic("Error: Cell at " + repr((this_x, this_y)) + " is placed at an inexistent column.") + } + + if max_x >= x_limit { + panic("Error: Cell at " + repr((this_x, this_y)) + " has a colspan of " + repr(cell.colspan) + ", which would exceed the available columns.") + } + + let cell_positions = positions-spanned-by(cell, x: this_x, y: this_y, x_limit: x_limit, y_limit: none) + + for position in cell_positions { + let (px, py) = position + let currently_there = grid-at(grid, px, py) + + if currently_there != none { + let parent_cell = get-parent-cell(currently_there, grid: grid) + + panic("Error: Multiple cells attempted to occupy the cell position at " + repr((px, py)) + ": one starting at " + repr((this_x, this_y)) + ", and one starting at " + repr((parent_cell.x, parent_cell.y))) + } + + // initial position => assign it to the cell's x/y + if position == (this_x, this_y) { + cell.x = this_x + cell.y = this_y + + // expand grid to allow placing this cell (including colspan / rowspan) + let grid_expand_res = grid-expand-to(grid, grid.width - 1, max_y) + + grid = grid_expand_res + y_limit = grid-count-rows(grid) + + let index = grid-index-at(this_x, this_y) + + if index > grid.items.len() { + panic("Internal tablex error: Could not expand grid to include cell at " + repr((this_x, this_y))) + } + grid.items.at(index) = cell + items.at(i) = cell + + // other secondary position (from colspan / rowspan) + } else { + let index = grid-index-at(px, py) + + grid.items.at(index) = occupied(x: px, y: py, parent_x: this_x, parent_y: this_y) // indicate this position's parent cell (to join them later) + } + } + + let next_pos = next-available-position(grid, x: this_x, y: this_y, x_limit: x_limit, y_limit: y_limit) + + prev_x = this_x + prev_y = this_y + + x = next_pos.at(0) + y = next_pos.at(1) + + if prev_y != y { + row_wrapped = true // we changed rows! + } + } + + // for missing cell positions: add empty cell + for (index, item) in grid.items.enumerate() { + if item == none { + grid.items.at(index) = new_empty_cell(grid, index: index) + } + } + + // while there are incomplete rows for some reason, add empty cells + while calc-mod(grid.items.len(), grid.width) != 0 { + grid.items.push(new_empty_cell(grid)) + } + + ( + grid: grid, + items: grid.items, + hlines: hlines, + vlines: vlines, + new_row_count: grid-count-rows(grid) + ) +} + +// -- end: grid functions -- + +// -- col/row size functions -- + +// Makes a cell's box, using the given options +// cell - The cell data (including content) +// width, height - The cell's dimensions +// inset - The table's inset +// align_default - The default alignment if the cell doesn't specify one +// fill_default - The default fill color / etc if the cell doesn't specify one +#let make-cell-box( + cell, + width: 0pt, height: 0pt, inset: 5pt, + align_default: left, + fill_default: none) = { + + let align_default = if type(align_default) == _function-type { + align_default(cell.x, cell.y) // column, row + } else { + align_default + } + + let fill_default = if type(fill_default) == _function-type { + fill_default(cell.x, cell.y) // row, column + } else { + fill_default + } + + let content = cell.content + + let inset = default-if-auto(cell.inset, inset) + + // use default align (specified in + // table 'align:') + // when the cell align is 'auto' + let cell_align = default-if-auto(cell.align, align_default) + + // same here for fill + let cell_fill = default-if-auto(cell.fill, fill_default) + + if type(cell_fill) == _array-type { + let fill_len = cell_fill.len() + + if fill_len == 0 { + // no fill values specified + // => no fill + cell_fill = none + } else if cell.x == auto { + // for some reason the cell x wasn't yet + // determined => just take the last + // fill value + cell_fill = cell_fill.last() + } else { + // use mod to make the fill value pattern + // repeat if there are more columns than + // fill values. + cell_fill = cell_fill.at(calc-mod(cell.x, fill_len)) + } + } + + if cell_fill != none and not is-color(cell_fill) { + panic("Tablex error: Invalid fill specified (must be either a function (column, row) -> fill, a color, an array of valid fill values, or 'none').") + } + + if type(cell_align) == _array-type { + let align_len = cell_align.len() + + if align_len == 0 { + // no alignment values specified + // => inherit from outside + cell_align = auto + } else if cell.x == auto { + // for some reason the cell x wasn't yet + // determined => just take the last + // alignment value + cell_align = cell_align.last() + } else { + // use mod to make the align value pattern + // repeat if there are more columns than + // align values. + cell_align = cell_align.at(calc-mod(cell.x, align_len)) + } + } + + if cell_align != auto and type(cell_align) not in (_align-type, _2d-align-type) { + panic("Tablex error: Invalid alignment specified (must be either a function (column, row) -> alignment, an alignment value - such as 'left' or 'center + top' -, an array of alignment values (one for each column), or 'auto').") + } + + let aligned_cell_content = if cell_align == auto { + [#content] + } else { + align(cell_align)[#content] + } + + if is-infinite-len(inset) { + panic("Tablex error: inset must not be infinite") + } + + box( + width: width, height: height, + inset: inset, fill: cell_fill, + // avoid #set problems + baseline: 0pt, + outset: 0pt, radius: 0pt, stroke: none, + aligned_cell_content) +} + +// Sums the sizes of fixed-size tracks (cols/rows). Anything else +// (auto, 1fr, ...) is ignored. +#let sum-fixed-size-tracks(tracks) = { + tracks.fold(0pt, (acc, el) => { + if type(el) == _length-type { + acc + el + } else { + acc + } + }) +} + +// Calculate the size of fraction tracks (cols/rows) (1fr, 2fr, ...), +// based on the remaining sizes (after fixed-size and auto columns) +#let determine-frac-tracks(tracks, remaining: 0pt, gutter: none) = { + let frac-tracks = tracks.enumerate().filter(t => type(t.at(1)) == _fraction-type) + + let amount-frac = frac-tracks.fold(0, (acc, el) => acc + (el.at(1) / 1fr)) + + if type(gutter) == _fraction-type { + amount-frac += (gutter / 1fr) * (tracks.len() - 1) + } + + let frac-width = if amount-frac > 0 and not is-infinite-len(remaining) { + remaining / amount-frac + } else { + 0pt + } + + if type(gutter) == _fraction-type { + gutter = frac-width * (gutter / 1fr) + } + + for (i, size) in frac-tracks { + tracks.at(i) = frac-width * (size / 1fr) + } + + (tracks: tracks, gutter: gutter) +} + +// Gets the last (rightmost) auto column a cell is inserted in, for +// due expansion +#let get-colspan-last-auto-col(cell, columns: none) = { + let cell-cols = range(cell.x, cell.x + cell.colspan) + let last_auto_col = none + + for (i, col) in columns.enumerate() { + if i in cell-cols and col == auto { + last_auto_col = max-if-not-none(last_auto_col, i) + } + } + + last_auto_col +} + +// Gets the last (bottom-most) auto row a cell is inserted in, for +// due expansion +#let get-rowspan-last-auto-row(cell, rows: none) = { + let cell-rows = range(cell.y, cell.y + cell.rowspan) + let last_auto_row = none + + for (i, row) in rows.enumerate() { + if i in cell-rows and row == auto { + last_auto_row = max-if-not-none(last_auto_row, i) + } + } + + last_auto_row +} + +// Given a cell that may span one or more columns, sums the +// sizes of the columns it spans, when those columns have fixed sizes. +// Useful to subtract from the total width to find out how much more +// should an auto column extend to have that cell fit in the table. +#let get-colspan-fixed-size-covered(cell, columns: none) = { + let cell-cols = range(cell.x, cell.x + cell.colspan) + let size = 0pt + + for (i, col) in columns.enumerate() { + if i in cell-cols and type(col) == _length-type { + size += col + } + } + size +} + +// Given a cell that may span one or more rows, sums the +// sizes of the rows it spans, when those rows have fixed sizes. +// Useful to subtract from the total height to find out how much more +// should an auto row extend to have that cell fit in the table. +#let get-rowspan-fixed-size-covered(cell, rows: none) = { + let cell-rows = range(cell.y, cell.y + cell.rowspan) + let size = 0pt + + for (i, row) in rows.enumerate() { + if i in cell-rows and type(row) == _length-type { + size += row + } + } + size +} + +// calculate the size of auto columns (based on the max width of their cells) +#let determine-auto-columns(grid: (), styles: none, columns: none, inset: none, align: auto, fit-spans: none, page-width: 0pt) = { + assert(styles != none, message: "Cannot measure auto columns without styles") + let total_auto_size = 0pt + let auto_sizes = () + let new_columns = columns + + let all-frac-columns = columns.enumerate().filter(i-col => type(i-col.at(1)) == _fraction-type).map(i-col => i-col.at(0)) + for (i, col) in columns.enumerate() { + if col == auto { + // max cell width + let col_size = grid-get-column(grid, i) + .fold(0pt, (max, cell) => { + if cell == none { + panic("Not enough cells specified for the given amount of rows and columns.") + } + + let pcell = get-parent-cell(cell, grid: grid) // in case this is a colspan + let last-auto-col = get-colspan-last-auto-col(pcell, columns: columns) + + let fit-this-span = if "fit-spans" in pcell and pcell.fit-spans != auto { + pcell.fit-spans.x + } else { + fit-spans.x + } + let this-cell-can-expand-columns = pcell.colspan == 1 or not fit-this-span + + // only expand the last auto column of a colspan, + // and only the amount necessary that isn't already + // covered by fixed size columns. + // However, ignore this cell if it is a colspan with + // `fit-spans.x == true` (it requests to not expand + // columns). + if last-auto-col == i and this-cell-can-expand-columns { + let cell-spans-all-frac-columns = pcell.colspan > 1 and all-frac-columns.len() > 0 and all-frac-columns.all(i => pcell.x <= i and i < (pcell.x + pcell.colspan)) + if cell-spans-all-frac-columns and page-width != 0pt and not is-infinite-len(page-width) { + // HEURISTIC (only effective when the page width isn't 'auto' / infinite): + // If this cell can expand auto cols, but it already + // spans all fractional columns, then don't expand + // this auto column, as the cell would already have + // all remaining available space for itself anyway + // through the fractional columns spanned. + // Effectively, ignore this colspan - it will already + // have the max space possible, since, eventually, + // auto columns will be reduced to fit in the available + // size. + // For 'auto'-width pages, fractional columns will + // always have 0pt width, so this doesn't apply. + return max + } + + // take extra inset as extra width or height on 'auto' + let cell_inset = default-if-auto(pcell.inset, inset) + + // simulate wrapping this cell in the final box, + // but with unlimited width and height available + // so we can measure its width. + let cell-box = make-cell-box( + pcell, + width: auto, height: auto, + inset: cell_inset, align_default: auto + ) + + let width = measure(cell-box, styles).width// + 2*cell_inset // the box already considers inset + + // here, we are excluding from the width of this cell + // at this column all width that was already covered by + // previous columns, so we need to specify 'new_columns' + // instead of 'columns' as the previous auto columns + // also have a fixed size now (we know their width). + let fixed_size = get-colspan-fixed-size-covered(pcell, columns: new_columns) + + calc.max(max, width - fixed_size, 0pt) + } else { + max + } + }) + + total_auto_size += col_size + auto_sizes.push((i, col_size)) + new_columns.at(i) = col_size + } + } + + (total: total_auto_size, sizes: auto_sizes, columns: new_columns) +} + +// Try to reduce the width of auto columns so that the table fits within the +// page width. +// Fair version of the algorithm, tries to shrink the minimum amount of columns +// possible. The same algorithm used by native tables. +// Auto columns that are too wide will receive equal amounts of the remaining +// width (the "fair-share"). +#let fit-auto-columns(available: 0pt, auto-cols: none, columns: none) = { + if is-infinite-len(available) { + // infinite space available => don't modify columns + return columns + } + + // Remaining space to share between auto columns. + // Starts as all of the available space (excluding fixed-width columns). + // Will reduce as we exclude auto columns from being resized. + let remaining = available + let auto-cols-to-resize = auto-cols.len() + + if auto-cols-to-resize <= 0 { + return columns + } + + // The fair-share must be the largest possible (to ensure maximum fairness) + // such that we can shrink the minimum amount of columns possible and, at the + // same time, ensure that the table won't cross the page width. + // To do this, we will try to divide the space evenly between each auto column + // to be resized. + // If one or more auto columns are smaller than that, then they don't need to be + // resized, so we will increase the fair share and check other columns, until + // either none needs to be resized (all are smaller than the fair share) + // or all columns to be resized are larger than the fair share. + let last-share + let fair-share = none + let fair-share-should-change = true + + // 1. Rule out auto columns from resizing, and determine the final fair share + // (the largest possible such that no columns are smaller than it). + // One iteration of this 'while' runs for each attempt at a value for the fair + // share. Once no non-excluded columns are smaller than the fair share + // (which would otherwise lead to them being excluded from being resized, and the + // fair share would increase), the loop stops, and we can resize down all columns + // larger than the fair share. + // The loop also stops if all auto columns would be smaller than the fair share, + // and thus there is nothing to resize. + while fair-share-should-change and auto-cols-to-resize > 0 { + last-share = fair-share + fair-share = remaining / auto-cols-to-resize + fair-share-should-change = false + + for (_, col) in auto-cols { + // 1. If it is smaller than the fair share, + // then it can keep its size, and we should + // update the fair share. + // 2. If it is larger than the last fair share, + // then it wasn't already excluded in any previous + // iterations. + if col <= fair-share and (last-share == none or col > last-share) { + remaining -= col + auto-cols-to-resize -= 1 + fair-share-should-change = true + } + } + } + + // 2. Resize any columns larger than the calculated fair share to the fair share. + for (i, col) in auto-cols { + if col > fair-share { + columns.at(i) = fair-share + } + } + + columns +} + +#let determine-column-sizes(grid: (), page_width: 0pt, styles: none, columns: none, inset: none, align: auto, col-gutter: none, fit-spans: none) = { + let columns = columns.map(c => { + if type(c) in (_length-type, _rel-len-type, _ratio-type) { + convert-length-to-pt(c, styles: styles, page-size: page_width) + } else if c == none { + 0pt + } else { + c + } + }) + + // what is the fixed size of the gutter? + // (calculate it later if it's fractional) + let fixed-size-gutter = if type(col-gutter) == _length-type { + col-gutter + } else { + 0pt + } + + let total_fixed_size = sum-fixed-size-tracks(columns) + fixed-size-gutter * (columns.len() - 1) + + let available_size = page_width - total_fixed_size + + // page_width == 0pt => page width is 'auto' + // so we don't have to restrict our table's size + if available_size >= 0pt or page_width == 0pt { + let auto_cols_result = determine-auto-columns(grid: grid, styles: styles, columns: columns, inset: inset, align: align, fit-spans: fit-spans, page-width: page_width) + let total_auto_size = auto_cols_result.total + let auto_sizes = auto_cols_result.sizes + columns = auto_cols_result.columns + + let remaining_size = available_size - total_auto_size + if remaining_size >= 0pt { + let frac_res = determine-frac-tracks( + columns, + remaining: remaining_size, + gutter: col-gutter + ) + + columns = frac_res.tracks + fixed-size-gutter = frac_res.gutter + } else { + // don't shrink on width 'auto' + if page_width != 0pt { + columns = fit-auto-columns( + available: available_size, + auto-cols: auto_sizes, + columns: columns + ) + } + + columns = columns.map(c => { + if type(c) == _fraction-type { + 0pt // no space left to be divided + } else { + c + } + }) + } + } else { + columns = columns.map(c => { + if c == auto or type(c) == _fraction-type { + 0pt // no space remaining! + } else { + c + } + }) + } + + ( + columns: columns, + gutter: if col-gutter == none { + none + } else { + fixed-size-gutter + } + ) +} + +// calculate the size of auto rows (based on the max height of their cells) +#let determine-auto-rows(grid: (), styles: none, columns: none, rows: none, align: auto, inset: none, fit-spans: none) = { + assert(styles != none, message: "Cannot measure auto rows without styles") + let total_auto_size = 0pt + let auto_sizes = () + let new_rows = rows + + for (i, row) in rows.enumerate() { + if row == auto { + // max cell height + let row_size = grid-get-row(grid, i) + .fold(0pt, (max, cell) => { + if cell == none { + panic("Not enough cells specified for the given amount of rows and columns.") + } + + let pcell = get-parent-cell(cell, grid: grid) // in case this is a rowspan + let last-auto-row = get-rowspan-last-auto-row(pcell, rows: rows) + + let fit-this-span = if "fit-spans" in pcell and pcell.fit-spans != auto { + pcell.fit-spans.y + } else { + fit-spans.y + } + let this-cell-can-expand-rows = pcell.rowspan == 1 or not fit-this-span + + // only expand the last auto row of a rowspan, + // and only the amount necessary that isn't already + // covered by fixed size rows. + // However, ignore this cell if it is a rowspan with + // `fit-spans.y == true` (it requests to not expand + // rows). + if last-auto-row == i and this-cell-can-expand-rows { + let width = get-colspan-fixed-size-covered(pcell, columns: columns) + + // take extra inset as extra width or height on 'auto' + let cell_inset = default-if-auto(pcell.inset, inset) + + let cell-box = make-cell-box( + pcell, + width: width, height: auto, + inset: cell_inset, align_default: align + ) + + // measure the cell's actual height, + // with its calculated width + // and with other constraints + let height = measure(cell-box, styles).height// + 2*cell_inset (box already considers inset) + + // here, we are excluding from the height of this cell + // at this row all height that was already covered by + // other rows, so we need to specify 'new_rows' instead + // of 'rows' as the previous auto rows also have a fixed + // size now (we know their height). + let fixed_size = get-rowspan-fixed-size-covered(pcell, rows: new_rows) + + calc.max(max, height - fixed_size, 0pt) + } else { + max + } + }) + + total_auto_size += row_size + auto_sizes.push((i, row_size)) + new_rows.at(i) = row_size + } + } + + (total: total_auto_size, sizes: auto_sizes, rows: new_rows) +} + +#let determine-row-sizes(grid: (), page_height: 0pt, styles: none, columns: none, rows: none, align: auto, inset: none, row-gutter: none, fit-spans: none) = { + let rows = rows.map(r => { + if type(r) in (_length-type, _rel-len-type, _ratio-type) { + convert-length-to-pt(r, styles: styles, page-size: page_height) + } else { + r + } + }) + + let auto_rows_res = determine-auto-rows( + grid: grid, columns: columns, rows: rows, styles: styles, align: align, inset: inset, fit-spans: fit-spans + ) + + let auto_size = auto_rows_res.total + rows = auto_rows_res.rows + + // what is the fixed size of the gutter? + // (calculate it later if it's fractional) + let fixed-size-gutter = if type(row-gutter) == _length-type { + row-gutter + } else { + 0pt + } + + let remaining = page_height - sum-fixed-size-tracks(rows) - auto_size - fixed-size-gutter * (rows.len() - 1) + + if remaining >= 0pt { // split fractions in one page + let frac_res = determine-frac-tracks(rows, remaining: remaining, gutter: row-gutter) + ( + rows: frac_res.tracks, + gutter: frac_res.gutter + ) + } else { + ( + rows: rows.map(r => { + if type(r) == _fraction-type { // no space remaining in this page or box + 0pt + } else { + r + } + }), + gutter: if row-gutter == none { + none + } else { + fixed-size-gutter + } + ) + } +} + +// Determine the size of 'auto' and 'fr' columns and rows +#let determine-auto-column-row-sizes( + grid: (), + page_width: 0pt, page_height: 0pt, + styles: none, + columns: none, rows: none, + inset: none, gutter: none, + align: auto, + fit-spans: none, +) = { + let columns_res = determine-column-sizes( + grid: grid, + page_width: page_width, styles: styles, columns: columns, + inset: inset, + align: align, + col-gutter: gutter.col, + fit-spans: fit-spans + ) + columns = columns_res.columns + gutter.col = columns_res.gutter + + let rows_res = determine-row-sizes( + grid: grid, + page_height: page_height, styles: styles, + columns: columns, // so we consider available width + rows: rows, + inset: inset, + align: align, + row-gutter: gutter.row, + fit-spans: fit-spans + ) + rows = rows_res.rows + gutter.row = rows_res.gutter + + ( + columns: columns, + rows: rows, + gutter: gutter + ) +} + +// -- end: col/row size functions -- + +// -- width/height utilities -- + +#let width-between(start: 0, end: none, columns: (), gutter: none, pre-gutter: false) = { + let col-gutter = default-if-none(default-if-none(gutter, (col: 0pt)).col, 0pt) + end = default-if-none(end, columns.len()) + + let col_range = range(start, calc.min(columns.len() + 1, end)) + + let sum = 0pt + for i in col_range { + sum += columns.at(i) + col-gutter + } + + // if the end is after all columns, there is + // no gutter at the end. + if pre-gutter or end == columns.len() { + sum = calc.max(0pt, sum - col-gutter) // remove extra gutter from last col + } + + sum +} + +#let height-between(start: 0, end: none, rows: (), gutter: none, pre-gutter: false) = { + let row-gutter = default-if-none(default-if-none(gutter, (row: 0pt)).row, 0pt) + end = default-if-none(end, rows.len()) + + let row_range = range(start, calc.min(rows.len() + 1, end)) + + let sum = 0pt + for i in row_range { + sum += rows.at(i) + row-gutter + } + + // if the end is after all rows, there is + // no gutter at the end. + if pre-gutter or end == rows.len() { + sum = calc.max(0pt, sum - row-gutter) // remove extra gutter from last row + } + + sum +} + +#let cell-width(x, colspan: 1, columns: (), gutter: none) = { + width-between(start: x, end: x + colspan, columns: columns, gutter: gutter, pre-gutter: true) +} + +#let cell-height(y, rowspan: 1, rows: (), gutter: none) = { + height-between(start: y, end: y + rowspan, rows: rows, gutter: gutter, pre-gutter: true) +} + +// override start and end for vlines and hlines (keep styling options and stuff) +#let v-or-hline-with-span(v_or_hline, start: none, end: none) = { + ( + ..v_or_hline, + start: start, + end: end, + parent: v_or_hline // the one that generated this + ) +} + +// check the subspan a hline or vline goes through inside a larger span +#let get-included-span(l_start, l_end, start: 0, end: 0, limit: 0) = { + if l_start in (none, auto) { + l_start = 0 + } + + if l_end in (none, auto) { + l_end = limit + } + + l_start = calc.max(0, l_start) + l_end = calc.min(end, limit) + + // ---- ==== or ==== ---- + if l_end < start or l_start > end { + return none + } + + // --##== ; ==##-- ; #### ; ... : intersection. + (calc.max(l_start, start), calc.min(l_end, end)) +} + +// restrict hlines and vlines to the cells' borders. +// i.e. +// | (vline) +// | +// (hline) ----====--- (= and || indicate intersection) +// | || +// ---- <--- sample cell +#let v-and-hline-spans-for-cell(cell, hlines: (), vlines: (), x_limit: 0, y_limit: 0, grid: ()) = { + // only draw lines from the parent cell + if is-tablex-occupied(cell) { + return ( + hlines: (), + vlines: () + ); + } + + let hlines = hlines + .filter(h => { + let y = h.y + + let in_top_or_bottom = y in (cell.y, cell.y + cell.rowspan) + + let hline_hasnt_already_ended = ( + h.end in (auto, none) // always goes towards the right + or h.end >= cell.x + cell.colspan // ends at or after this cell + ) + + (in_top_or_bottom + and hline_hasnt_already_ended) + }) + .map(h => { + // get the intersection between the hline and the cell's x-span. + let span = get-included-span(h.start, h.end, start: cell.x, end: cell.x + cell.colspan, limit: x_limit) + + if span == none { // no intersection! + none + } else { + v-or-hline-with-span(h, start: span.at(0), end: span.at(1)) + } + }) + .filter(x => x != none) + + let vlines = vlines + .filter(v => { + let x = v.x + + let at_left_or_right = x in (cell.x, cell.x + cell.colspan) + + let vline_hasnt_already_ended = ( + v.end in (auto, none) // always goes towards the bottom + or v.end >= cell.y + cell.rowspan // ends at or after this cell + ) + + (at_left_or_right + and vline_hasnt_already_ended) + }) + .map(v => { + // get the intersection between the hline and the cell's x-span. + let span = get-included-span(v.start, v.end, start: cell.y, end: cell.y + cell.rowspan, limit: y_limit) + + if span == none { // no intersection! + none + } else { + v-or-hline-with-span(v, start: span.at(0), end: span.at(1)) + } + }) + .filter(x => x != none) + + ( + hlines: hlines, + vlines: vlines + ) +} + +// Are two hlines the same? +// (Check to avoid double drawing) +#let is-same-hline(a, b) = ( + is-tablex-hline(a) + and is-tablex-hline(b) + and a.y == b.y + and a.start == b.start + and a.end == b.end + and a.gutter-restrict == b.gutter-restrict +) + +#let _largest-stroke-among-lines(lines, stroke-auto: 1pt, styles: none) = ( + calc.max(0pt, ..lines.map(l => stroke-len(l.stroke, stroke-auto: stroke-auto, styles: styles))) +) + +#let _largest-stroke-among-hlines-at-y(y, hlines: none, stroke-auto: 1pt, styles: none) = { + _largest-stroke-among-lines(hlines.filter(h => h.y == y), stroke-auto: stroke-auto, styles: styles) +} + +#let _largest-stroke-among-vlines-at-x(x, vlines: none, stroke-auto: 1pt, styles: none) = { + _largest-stroke-among-lines(vlines.filter(v => v.x == x), stroke-auto: stroke-auto, styles: styles) +} + +// -- end: width/height utilities -- + +// -- drawing -- + +#let parse-stroke(stroke) = { + if is-color(stroke) { + stroke + 1pt + } else if type(stroke) in (_length-type, _rel-len-type, _ratio-type, _stroke-type, _dict-type) or stroke in (none, auto) { + stroke + } else { + panic("Invalid stroke '" + repr(stroke) + "'.") + } +} + +// How much should this line expand? +// If it's not at the edge of the parent line => don't expand +// spanned-tracks-len: row_len (if vline), col_len (if hline) +#let get-actual-expansion(line, spanned-tracks-len: 0) = { + // TODO: better handle negative expansion + if line.expand in (none, (none, none), auto, (auto, auto)) { + return (none, none) + } + if type(line.expand) != _array-type { + line.expand = (line.expand, line.expand) + } + + let parent = get-top-parent(line) + let parent-start = default-if-auto-or-none(parent.start, 0) + let parent-end = default-if-auto-or-none(parent.end, spanned-tracks-len) + + let start = default-if-auto-or-none(line.start, 0) + let end = default-if-auto-or-none(line.end, spanned-tracks-len) + + let expansion = (none, none) + + if start == parent-start { // starts where its parent starts + expansion.at(0) = default-if-auto(line.expand.at(0), 0pt) // => expand to the left + } + + if end == parent-end { // ends where its parent ends + expansion.at(1) = default-if-auto(line.expand.at(1), 0pt) // => expand to the right + } + + expansion +} + +#let draw-hline( + hline, + initial_x: 0, initial_y: 0, columns: (), rows: (), stroke: auto, vlines: (), gutter: none, pre-gutter: false, + styles: none, + rightmost_x: 0, rtl: false, +) = { + let start = hline.start + let end = hline.end + let stroke-auto = parse-stroke(stroke) + let stroke = default-if-auto(hline.stroke, stroke) + let stroke = parse-stroke(stroke) + + if default-if-auto-or-none(start, 0) == default-if-auto-or-none(end, columns.len()) { return } + + if gutter != none and gutter.row != none and ((pre-gutter and hline.gutter-restrict == bottom) or (not pre-gutter and hline.gutter-restrict == top)) { + return + } + + let expand = get-actual-expansion(hline, spanned-tracks-len: columns.len()) + let left-expand = default-if-auto-or-none(expand.at(0), 0pt) + let right-expand = default-if-auto-or-none(expand.at(1), 0pt) + + if default-if-auto(hline.stroke-expand, true) == true { + let largest-stroke = _largest-stroke-among-vlines-at-x.with(vlines: vlines, stroke-auto: stroke-auto, styles: styles) + left-expand += largest-stroke(default-if-auto-or-none(start, 0)) / 2 // expand to the left to close stroke gap + right-expand += largest-stroke(default-if-auto-or-none(end, columns.len())) / 2 // close stroke gap to the right + } + + let y = height-between(start: initial_y, end: hline.y, rows: rows, gutter: gutter, pre-gutter: pre-gutter) + let start_x = width-between(start: initial_x, end: start, columns: columns, gutter: gutter, pre-gutter: false) - left-expand + let end_x = width-between(start: initial_x, end: end, columns: columns, gutter: gutter, pre-gutter: hline.stop-pre-gutter == true) + right-expand + + if end_x - start_x < 0pt { + return // negative length + } + + if rtl { + // invert the line (start from the right instead of from the left) + start_x = rightmost_x - start_x + end_x = rightmost_x - end_x + } + + let start = ( + start_x, + y + ) + let end = ( + end_x, + y + ) + + if stroke != auto { + if stroke != none { + line(start: start, end: end, stroke: stroke) + } + } else { + line(start: start, end: end) + } +} + +#let draw-vline( + vline, + initial_x: 0, initial_y: 0, columns: (), rows: (), stroke: auto, + gutter: none, hlines: (), pre-gutter: false, stop-before-row-gutter: false, + styles: none, + rightmost_x: 0, rtl: false, +) = { + let start = vline.start + let end = vline.end + let stroke-auto = parse-stroke(stroke) + let stroke = default-if-auto(vline.stroke, stroke) + let stroke = parse-stroke(stroke) + + if default-if-auto-or-none(start, 0) == default-if-auto-or-none(end, rows.len()) { return } + + if gutter != none and gutter.col != none and ((pre-gutter and vline.gutter-restrict == right) or (not pre-gutter and vline.gutter-restrict == left)) { + return + } + + let expand = get-actual-expansion(vline, spanned-tracks-len: rows.len()) + let top-expand = default-if-auto-or-none(expand.at(0), 0pt) + let bottom-expand = default-if-auto-or-none(expand.at(1), 0pt) + + if default-if-auto(vline.stroke-expand, true) == true { + let largest-stroke = _largest-stroke-among-hlines-at-y.with(hlines: hlines, stroke-auto: stroke-auto, styles: styles) + top-expand += largest-stroke(default-if-auto-or-none(start, 0)) / 2 // close stroke gap to the top + bottom-expand += largest-stroke(default-if-auto-or-none(end, rows.len())) / 2 // close stroke gap to the bottom + } + + let x = width-between(start: initial_x, end: vline.x, columns: columns, gutter: gutter, pre-gutter: pre-gutter) + let start_y = height-between(start: initial_y, end: start, rows: rows, gutter: gutter) - top-expand + let end_y = height-between(start: initial_y, end: end, rows: rows, gutter: gutter, pre-gutter: stop-before-row-gutter or vline.stop-pre-gutter == true) + bottom-expand + + if end_y - start_y < 0pt { + return // negative length + } + + if rtl { + // invert the vertical line's x pos (start from the right instead of from the left) + x = rightmost_x - x + } + + let start = ( + x, + start_y + ) + let end = ( + x, + end_y + ) + + if stroke != auto { + if stroke != none { + line(start: start, end: end, stroke: stroke) + } + } else { + line(start: start, end: end) + } +} + +// -- end: drawing + +// main functions + +// Gets a state variable that holds the page's max x ("width") and max y ("height"), +// considering the left and top margins. +// Requires placing 'get-page-dim-writer(the_returned_state)' on the +// document. +// The id is to differentiate the state for each table. +#let get-page-dim-state(id) = state("tablex_tablex_page_dims__" + repr(id), (width: 0pt, height: 0pt, top_left: none, bottom_right: none)) + +// A little trick to get the page max width and max height. +// Places a component on the page (or outer container)'s top left, +// and one on the page's bottom right, and subtracts their coordinates. +// +// Must be fed a state variable, which is updated with (width: max x, height: max y). +// The content it returns must be placed in the document for the page state to be +// written to. +// +// NOTE: This function cannot differentiate between the actual page +// and a possible box or block where the component using this function +// could be contained in. +#let get-page-dim-writer() = locate(w_loc => { + let table_id = _tablex-table-counter.at(w_loc) + let page_dim_state = get-page-dim-state(table_id) + + place(top + left, locate(loc => { + page_dim_state.update(s => { + if s.top_left != none { + s + } else { + let pos = loc.position() + let width = s.width - pos.x + let height = s.width - pos.y + (width: width, height: height, top_left: pos, bottom_right: s.bottom_right) + } + }) + })) + + place(bottom + right, locate(loc => { + page_dim_state.update(s => { + if s.bottom_right != none { + s + } else { + let pos = loc.position() + let width = s.width + pos.x + let height = s.width + pos.y + (width: width, height: height, top_left: s.top_left, bottom_right: pos) + } + }) + })) +}) + +// Draws a row group using locate() and a block(). +#let draw-row-group( + row-group, + is-header: false, + header-pages-state: none, + first-row-group: none, + columns: none, rows: none, + stroke: none, + gutter: none, + repeat-header: false, + styles: none, + min-pos: none, + max-pos: none, + header-hlines-have-priority: true, + rtl: false, + table-loc: none, + total-width: none, + global-hlines: (), + global-vlines: (), +) = { + let width-between = width-between.with(columns: columns, gutter: gutter) + let height-between = height-between.with(rows: rows, gutter: gutter) + let draw-hline = draw-hline.with(columns: columns, rows: rows, stroke: stroke, gutter: gutter, vlines: global-vlines, styles: styles) + let draw-vline = draw-vline.with(columns: columns, rows: rows, stroke: stroke, gutter: gutter, hlines: global-hlines, styles: styles) + + let group-rows = row-group.rows + let hlines = row-group.hlines + let vlines = row-group.vlines + let (start-y, end-y) = row-group.y_span + + locate(loc => { + // let old_page = latest-page-state.at(loc) + // let this_page = loc.page() + + // let page_turned = not is-header and old_page not in (this_page, -1) + let pos = loc.position() + let page = pos.page + let rel_page = page - table-loc.page() + 1 + + let at_top = pos.y == min-pos.y // to guard against re-draw issues + let header_pages = header-pages-state.at(loc) + let header_count = header_pages.len() + let page_turned = page not in header_pages + + // draw row group + block( + breakable: false, + fill: none, radius: 0pt, stroke: none, + { + let added_header_height = 0pt // if we added a header, move down + + // page turned => add header + if page_turned and at_top and not is-header { + if repeat-header != false { + header-pages-state.update(l => l + (page,)) + if (repeat-header == true) or (type(repeat-header) == _int-type and rel_page <= repeat-header) or (type(repeat-header) == _array-type and rel_page in repeat-header) { + let measures = measure(first-row-group.content, styles) + place(top+left, first-row-group.content) // add header + added_header_height = measures.height + } + } + } + + let row_gutter_dy = default-if-none(gutter.row, 0pt) + + let first_x = none + let first_y = none + let rightmost_x = none + + let row_heights = array-sum(rows.slice(start-y, end-y + 1), zero: 0pt) + + let first_row = true + for row in group-rows { + for cell_box in row { + let x = cell_box.cell.x + let y = cell_box.cell.y + first_x = default-if-none(first_x, x) + first_y = default-if-none(first_y, y) + rightmost_x = default-if-none(rightmost_x, width-between(start: first_x, end: none)) + + // where to place the cell (horizontally) + let dx = width-between(start: first_x, end: x) + + // TODO: consider implementing RTL before the rendering + // stage (perhaps by inverting 'x' positions on cells + // and lines beforehand). + if rtl { + // invert cell's x position (start from the right) + dx = rightmost_x - dx + // assume the cell doesn't start at the very end + // (that would be weird) + // Here we have to move dx back a bit as, after + // inverting it, it'd be the right edge of the cell; + // we need to keep it as the left edge's x position, + // as #place works with the cell's left edge. + // To do that, we subtract the cell's width from dx. + dx -= width-between(start: x, end: x + cell_box.cell.colspan) + } + + // place the cell! + place(top+left, + dx: dx, + dy: height-between(start: first_y, end: y) + added_header_height, + cell_box.box) + + // let box_h = measure(cell_box.box, styles).height + // tallest_box_h = calc.max(tallest_box_h, box_h) + } + first_row = false + } + + let row_group_height = row_heights + added_header_height + (row_gutter_dy * group-rows.len()) + + let is_last_row = not is-infinite-len(max-pos.y) and pos.y + row_group_height + row_gutter_dy >= max-pos.y + + if is_last_row { + row_group_height -= row_gutter_dy + // one less gutter at the end + } + + hide(rect(width: total-width, height: row_group_height)) + + let draw-hline = draw-hline.with(initial_x: first_x, initial_y: first_y, rightmost_x: rightmost_x, rtl: rtl) + let draw-vline = draw-vline.with(initial_x: first_x, initial_y: first_y, rightmost_x: rightmost_x, rtl: rtl) + + // ensure the lines are drawn absolutely, after the header + let draw-hline = (..args) => place(top + left, dy: added_header_height, draw-hline(..args)) + let draw-vline = (..args) => place(top + left, dy: added_header_height, draw-vline(..args)) + + let header_last_y = if first-row-group != none { + first-row-group.row_group.y_span.at(1) + } else { + none + } + // if this is the second row, and the header's hlines + // do not have priority (thus are not drawn by them, + // otherwise they'd repeat on every page), then + // we draw its hlines for the header, below it. + let hlines = if not header-hlines-have-priority and not is-header and start-y == header_last_y + 1 { + let hlines_below_header = first-row-group.row_group.hlines.filter(h => h.y == header_last_y + 1) + + hlines + hlines_below_header + } else { + hlines + } + + for hline in hlines { + // only draw the top hline + // if header's wasn't already drawn + if hline.y == start-y { + let header_last_y = if first-row-group != none { + first-row-group.row_group.y_span.at(1) + } else { + none + } + // pre-gutter is always false here, as we assume + // hlines at the top of this row are handled + // at pre-gutter by the preceding row, + // and at post-gutter by this (the following) row. + // these if's are to check if we should indeed + // draw this hline, or if the previous row / + // the header should take care of it. + if not header-hlines-have-priority and not is-header and start-y == header_last_y + 1 { + // second row (after header, and it has no hline priority). + draw-hline(hline, pre-gutter: false) + } else if hline.y == 0 { + // hline at the very top of the table. + draw-hline(hline, pre-gutter: false) + } else if not page_turned and gutter.row != none and hline.gutter-restrict != top { + // this hline, at the top of this row group, + // isn't restricted to a pre-gutter position, + // so let's draw it right above us. + // The page turn check is important: + // the hline should not be drawn if the header + // was repeated and its own hlines have + // priority. + draw-hline(hline, pre-gutter: false) + } else if page_turned and (added_header_height == 0pt or not header-hlines-have-priority) { + draw-hline(hline, pre-gutter: false) + // no header repeated, but still at the top of the current page + } + } else { + if hline.y == end-y + 1 and ( + (is-header and not header-hlines-have-priority) + or (gutter.row != none and hline.gutter-restrict == bottom)) { + // this hline is after all cells + // in the row group, and either + // this is the header and its hlines + // don't have priority (=> the row + // groups below it - if repeated - + // should draw the hlines above them), + // or the hline is restricted to + // post-gutter => let the next + // row group draw it. + continue + } + + // normally, only draw the bottom hlines + // (and both their pre-gutter and + // post-gutter variations) + draw-hline(hline, pre-gutter: true) + + // don't draw the post-row gutter hline + // if this is the last row in the page, + // the last row in the row group + // (=> the next row group will + // place the hline above it, so that + // lines break properly between pages), + // or the last row in the whole table. + if gutter.row != none and hline.y < rows.len() and hline.y < end-y + 1 and not is_last_row { + draw-hline(hline, pre-gutter: false) + } + } + } + + for vline in vlines { + draw-vline(vline, pre-gutter: true, stop-before-row-gutter: is_last_row) + + // don't draw the post-col gutter vline + // if this is the last vline + if gutter.col != none and vline.x < columns.len() { + draw-vline(vline, pre-gutter: false, stop-before-row-gutter: is_last_row) + } + } + }) + }) +} + +// Generates groups of rows. +// By default, 1 row + rows from its rowspan cells = 1 row group. +// The first row group is the header, which is repeated across pages. +#let generate-row-groups( + grid: none, + columns: none, rows: none, + stroke: none, inset: none, + gutter: none, + fill: none, + align: none, + hlines: none, vlines: none, + repeat-header: false, + styles: none, + header-hlines-have-priority: true, + min-pos: none, + max-pos: none, + header-rows: 1, + rtl: false, + table-loc: none, + table-id: none, +) = { + let col_len = columns.len() + let row_len = rows.len() + + // specialize some functions for the given grid, columns and rows + let v-and-hline-spans-for-cell = v-and-hline-spans-for-cell.with(vlines: vlines, x_limit: col_len, y_limit: row_len, grid: grid) + let cell-width = cell-width.with(columns: columns, gutter: gutter) + let cell-height = cell-height.with(rows: rows, gutter: gutter) + let width-between = width-between.with(columns: columns, gutter: gutter) + let height-between = height-between.with(rows: rows, gutter: gutter) + + // each row group is an unbreakable unit of rows. + // In general, they're just one row. However, they can be multiple rows + // if one of their cells spans multiple rows. + let first_row_group = none + + let header_pages = state("tablex_tablex_header_pages__" + repr(table-id), (table-loc.page(),)) + let this_row_group = (rows: ((),), hlines: (), vlines: (), y_span: (0, 0)) + + let total_width = width-between(end: none) + + let row_group_add_counter = 1 // how many more rows are going to be added to the latest row group + let current_row = 0 + let header_rows_count = calc.min(row_len, header-rows) + + for row in range(0, row_len) { + // maximum cell total rowspan in this row + let max_rowspan = 0 + + for column in range(0, col_len) { + let cell = grid-at(grid, column, row) + let lines_dict = v-and-hline-spans-for-cell(cell, hlines: hlines) + let hlines = lines_dict.hlines + let vlines = lines_dict.vlines + + if is-tablex-cell(cell) { + // ensure row-spanned rows are in the same group + row_group_add_counter = calc.max(row_group_add_counter, cell.rowspan) + + let width = cell-width(cell.x, colspan: cell.colspan) + let height = cell-height(cell.y, rowspan: cell.rowspan) + + let cell_box = make-cell-box( + cell, + width: width, height: height, inset: inset, + align_default: align, + fill_default: fill) + + this_row_group.rows.last().push((cell: cell, box: cell_box)) + + let hlines = hlines + .filter(h => + this_row_group.hlines + .filter(is-same-hline.with(h)) + .len() == 0) + + let vlines = vlines + .filter(v => v not in this_row_group.vlines) + + this_row_group.hlines += hlines + this_row_group.vlines += vlines + } + } + + current_row += 1 + row_group_add_counter = calc.max(0, row_group_add_counter - 1) // one row added + header_rows_count = calc.max(0, header_rows_count - 1) // ensure at least the amount of requested header rows was added + + // added all pertaining rows to the group + // now we can draw it + if row_group_add_counter <= 0 and header_rows_count <= 0 { + row_group_add_counter = 1 + + let row-group = this_row_group + + // get where the row starts and where it ends + let (start_y, end_y) = row-group.y_span + + let next_y = end_y + 1 + + this_row_group = (rows: ((),), hlines: (), vlines: (), y_span: (next_y, next_y)) + + let is_header = first_row_group == none + let content = draw-row-group( + row-group, + is-header: is_header, + header-pages-state: header_pages, + first-row-group: first_row_group, + columns: columns, rows: rows, + stroke: stroke, + gutter: gutter, + repeat-header: repeat-header, + total-width: total_width, + table-loc: table-loc, + header-hlines-have-priority: header-hlines-have-priority, + rtl: rtl, + min-pos: min-pos, + max-pos: max-pos, + styles: styles, + global-hlines: hlines, + global-vlines: vlines, + ) + + if is_header { // this is now the header group. + first_row_group = (row_group: row-group, content: content) // 'content' to repeat later + } + + (content,) + } else { + this_row_group.rows.push(()) + this_row_group.y_span.at(1) += 1 + } + } +} + +// -- end: main functions + +// option parsing functions + +#let _parse-lines( + hlines, vlines, + page-width: none, page-height: none, + styles: none +) = { + let parse-func(line, page-size: none) = { + line.stroke-expand = line.stroke-expand == true + line.expand = default-if-auto(line.expand, none) + if type(line.expand) != _array-type and line.expand != none { + line.expand = (line.expand, line.expand) + } + line.expand = if line.expand == none { + none + } else { + line.expand.slice(0, 2).map(e => { + if e == none { + e + } else { + e = default-if-auto(e, 0pt) + if type(e) not in (_length-type, _rel-len-type, _ratio-type) { + panic("'expand' argument to lines must be a pair (length, length).") + } + + convert-length-to-pt(e, styles: styles, page-size: page-size) + } + }) + } + + line + } + ( + hlines: hlines.map(parse-func.with(page-size: page-width)), + vlines: vlines.map(parse-func.with(page-size: page-height)) + ) +} + +// Parses 'auto-lines', generating the corresponding lists of +// new hlines and vlines +#let generate-autolines(auto-lines: false, auto-hlines: auto, auto-vlines: auto, hlines: none, vlines: none, col_len: none, row_len: none) = { + let auto-hlines = default-if-auto(auto-hlines, auto-lines) + let auto-vlines = default-if-auto(auto-vlines, auto-lines) + + let new_hlines = () + let new_vlines = () + + if auto-hlines { + new_hlines = range(0, row_len + 1) + .filter(y => hlines.filter(h => h.y == y).len() == 0) + .map(y => hlinex(y: y)) + } + + if auto-vlines { + new_vlines = range(0, col_len + 1) + .filter(x => vlines.filter(v => v.x == x).len() == 0) + .map(x => vlinex(x: x)) + } + + (new_hlines: new_hlines, new_vlines: new_vlines) +} + +#let parse-gutters(col-gutter: auto, row-gutter: auto, gutter: auto, styles: none, page-width: 0pt, page-height: 0pt) = { + col-gutter = default-if-auto(col-gutter, gutter) + row-gutter = default-if-auto(row-gutter, gutter) + + col-gutter = default-if-auto(col-gutter, 0pt) + row-gutter = default-if-auto(row-gutter, 0pt) + + if type(col-gutter) in (_length-type, _rel-len-type, _ratio-type) { + col-gutter = convert-length-to-pt(col-gutter, styles: styles, page-size: page-width) + } + + if type(row-gutter) in (_length-type, _rel-len-type, _ratio-type) { + row-gutter = convert-length-to-pt(row-gutter, styles: styles, page-size: page-width) + } + + (col: col-gutter, row: row-gutter) +} + +// Accepts a map-X param, and verifies whether it's a function or none/auto. +#let validate-map-func(map-func) = { + if map-func not in (none, auto) and type(map-func) != _function-type { + panic("Tablex error: Map parameters, if specified (not 'none'), must be functions.") + } + + map-func +} + +#let apply-maps( + grid: (), + hlines: (), + vlines: (), + map-hlines: none, + map-vlines: none, + map-rows: none, + map-cols: none, +) = { + if type(map-vlines) == _function-type { + vlines = vlines.map(vline => { + let vline = map-vlines(vline) + if not is-tablex-vline(vline) { + panic("'map-vlines' function returned a non-vline.") + } + vline + }) + } + + if type(map-hlines) == _function-type { + hlines = hlines.map(hline => { + let hline = map-hlines(hline) + if not is-tablex-hline(hline) { + panic("'map-hlines' function returned a non-hline.") + } + hline + }) + } + + let should-map-rows = type(map-rows) == _function-type + let should-map-cols = type(map-cols) == _function-type + + if not should-map-rows and not should-map-cols { + return (grid: grid, hlines: hlines, vlines: vlines) + } + + let col-len = grid.width + let row-len = grid-count-rows(grid) + + if should-map-rows { + for row in range(row-len) { + let original-cells = grid-get-row(grid, row) + + // occupied cells = none for the outer user + let cells = map-rows(row, original-cells.map(c => { + if is-tablex-occupied(c) { none } else { c } + })) + + if type(cells) != _array-type { + panic("Tablex error: 'map-rows' returned something that isn't an array.") + } + + if cells.len() != original-cells.len() { + panic("Tablex error: 'map-rows' returned " + str(cells.len()) + " cells, when it should have returned exactly " + str(original-cells.len()) + ".") + } + + for (i, cell) in cells.enumerate() { + let orig-cell = original-cells.at(i) + if not is-tablex-cell(orig-cell) { + // only modify non-occupied cells + continue + } + + if not is-tablex-cell(cell) { + panic("Tablex error: 'map-rows' returned a non-cell.") + } + + let x = cell.x + let y = cell.y + + if type(x) != _int-type or type(y) != _int-type or x < 0 or y < 0 or x >= col-len or y >= row-len { + panic("Tablex error: 'map-rows' returned a cell with invalid coordinates.") + } + + if y != row { + panic("Tablex error: 'map-rows' returned a cell in a different row (the 'y' must be kept the same).") + } + + if cell.colspan != orig-cell.colspan or cell.rowspan != orig-cell.rowspan { + panic("Tablex error: Please do not change the colspan or rowspan of a cell in 'map-rows'.") + } + + cell.content = [#cell.content] + grid.items.at(grid-index-at(cell.x, cell.y, grid: grid)) = cell + } + } + } + + if should-map-cols { + for column in range(col-len) { + let original-cells = grid-get-column(grid, column) + + // occupied cells = none for the outer user + let cells = map-cols(column, original-cells.map(c => { + if is-tablex-occupied(c) { none } else { c } + })) + + if type(cells) != _array-type { + panic("Tablex error: 'map-cols' returned something that isn't an array.") + } + + if cells.len() != original-cells.len() { + panic("Tablex error: 'map-cols' returned " + str(cells.len()) + " cells, when it should have returned exactly " + str(original-cells.len()) + ".") + } + + for (i, cell) in cells.enumerate() { + let orig-cell = original-cells.at(i) + if not is-tablex-cell(orig-cell) { + // only modify non-occupied cells + continue + } + + if not is-tablex-cell(cell) { + panic("Tablex error: 'map-cols' returned a non-cell.") + } + + let x = cell.x + let y = cell.y + + if type(x) != _int-type or type(y) != _int-type or x < 0 or y < 0 or x >= col-len or y >= row-len { + panic("Tablex error: 'map-cols' returned a cell with invalid coordinates.") + } + if x != column { + panic("Tablex error: 'map-cols' returned a cell in a different column (the 'x' must be kept the same).") + } + if cell.colspan != orig-cell.colspan or cell.rowspan != orig-cell.rowspan { + panic("Tablex error: Please do not change the colspan or rowspan of a cell in 'map-cols'.") + } + + cell.content = [#cell.content] + grid.items.at(grid-index-at(cell.x, cell.y, grid: grid)) = cell + } + } + } + + (grid: grid, hlines: hlines, vlines: vlines) +} + +#let validate-header-rows(header-rows) = { + header-rows = default-if-auto(default-if-none(header-rows, 0), 1) + + if type(header-rows) != _int-type or header-rows < 0 { + panic("Tablex error: 'header-rows' must be a (positive) integer.") + } + + header-rows +} + +#let validate-repeat-header(repeat-header, header-rows: none) = { + if header-rows == none or header-rows < 0 { + return false // cannot repeat an empty header + } + + repeat-header = default-if-auto(default-if-none(repeat-header, false), false) + + if type(repeat-header) not in (_bool-type, _int-type, _array-type) { + panic("Tablex error: 'repeat-header' must be a boolean (true - always repeat the header, false - never), an integer (amount of pages for which to repeat the header), or an array of integers (relative pages in which the header should repeat).") + } else if type(repeat-header) == _array-type and repeat-header.any(i => type(i) != _int-type) { + panic("Tablex error: 'repeat-header' cannot be an array of anything other than integers!") + } + + repeat-header +} + +#let validate-header-hlines-priority( + header-hlines-have-priority +) = { + header-hlines-have-priority = default-if-auto(default-if-none(header-hlines-have-priority, true), true) + + if type(header-hlines-have-priority) != _bool-type { + panic("Tablex error: 'header-hlines-have-priority' option must be a boolean.") + } + + header-hlines-have-priority +} + +// 'validate-fit-spans' is needed by grid, and is thus in the common section + +// -- end: option parsing + +// Creates a table. +// +// OPTIONS: +// columns: table column sizes (array of sizes, +// or a single size for 1 column) +// +// rows: row sizes (same format as columns) +// +// align: how to align cells (alignment, array of alignments +// (one for each column), or a function +// (col, row) => alignment) +// +// items: The table items, as specified by the columns +// and rows. Can also be cellx, hlinex and vlinex objects. +// +// fill: how to fill cells (color/none, array of colors +// (one for each column), or a function (col, row) => color) +// +// stroke: how to draw the table lines (stroke) +// column-gutter: optional separation (length) between columns +// row-gutter: optional separation (length) between rows +// gutter: quickly apply a length to both column- and row-gutter +// +// repeat-header: true = repeat the first row (or rowspan) +// on all pages; integer = repeat for the first n pages; +// array of integers = repeat on exactly those pages +// (where 1 is the first, so ignored); false = do not repeat +// the first row group (default). +// +// header-rows: minimum amount of rows for the repeatable +// header. 1 by default. Automatically increases if +// one of the cells is a rowspan that would go beyond the +// given amount of rows. For example, if 3 is given, +// then at least the first 3 rows will repeat. +// +// header-hlines-have-priority: if true, the horizontal +// lines below the header being repeated take priority +// over the rows they appear atop of on further pages. +// If false, they draw their own horizontal lines. +// Defaults to true. +// +// rtl: if true, the table is horizontally flipped. +// That is, cells and lines are placed in the opposite order +// (starting from the right), and horizontal lines are flipped. +// This is meant to simulate the behavior of default Typst tables when +// 'set text(dir: rtl)' is used, and is useful when writing in a language +// with a RTL (right-to-left) script. +// Defaults to false. +// +// auto-lines: true = applies true to both auto-hlines and +// auto-vlines; false = applies false to both. +// Their values override this one unless they are 'auto'. +// +// auto-hlines: true = draw a horizontal line on every line +// without a manual horizontal line specified; false = do +// not draw any horizontal line without manual specification. +// Defaults to 'auto' (follows 'auto-lines'). +// +// auto-vlines: true = draw a vertical line on every column +// without a manual vertical line specified; false = requires +// manual specification. Defaults to 'auto' (follows +// 'auto-lines') +// +// map-cells: Takes a cellx and returns another cellx (or +// content). +// +// map-hlines: Takes each horizontal line (hlinex) and +// returns another. +// +// map-vlines: Takes each vertical line (vlinex) and +// returns another. +// +// map-rows: Maps each row of cells. +// Takes (row_num, cell_array) and returns +// the modified cell_array. Note that, here, they +// cannot be sent to another row. Also, cells may be +// 'none' if they're a position taken by a cell in a +// colspan/rowspan. +// +// map-cols: Maps each column of cells. +// Takes (col_num, cell_array) and returns +// the modified cell_array. Note that, here, they +// cannot be sent to another row. Also, cells may be +// 'none' if they're a position taken by a cell in a +// colspan/rowspan. +// +// fit-spans: Determine if rowspans and colspans should fit within their +// spanned 'auto'-sized tracks (columns and rows) instead of causing them to +// expand based on the rowspan/colspan cell's size. (Most users of tablex +// shouldn't have to change this option.) +// Must either be a dictionary '(x: true/false, y: true/false)' or a boolean +// true/false (which is converted to the (x: value, y: value) format with both +// 'x' and 'y' being set to the same value; for instance, 'true' becomes +// '(x: true, y: true)'). +// Setting 'x' to 'false' (the default) means that colspans will cause the last +// (rightmost) auto column they span to expand if the cell's contents are too +// long; setting 'x' to 'true' negates this, and auto columns will ignore the +// size of colspans. Similarly, setting 'y' to 'false' (the default) means that +// rowspans will cause the last (bottommost) auto row they span to expand if +// the cell's contents are too tall; setting 'y' to 'true' causes auto rows to +// ignore the size of rowspans. +// This setting is mostly useful when you have a colspan or a rowspan spanning +// tracks with fractional (1fr, 2fr, ...) size, which can cause the fractional +// track to have less or even zero size, compromising all other cells in it. +// If you're facing this problem, you may want experiment with setting this +// option to '(x: true)' (if this is affecting columns) or 'true' (for rows +// too, same as '(x: true, y: true)'). +// Note that this option can also be set in a per-cell basis through cellx(). +// See its reference for more information. +#let tablex( + columns: auto, rows: auto, + inset: 5pt, + align: auto, + fill: none, + stroke: auto, + column-gutter: auto, row-gutter: auto, + gutter: none, + repeat-header: false, + header-rows: 1, + header-hlines-have-priority: true, + rtl: false, + auto-lines: true, + auto-hlines: auto, + auto-vlines: auto, + map-cells: none, + map-hlines: none, + map-vlines: none, + map-rows: none, + map-cols: none, + fit-spans: false, + ..items +) = { + _tablex-table-counter.step() + + get-page-dim-writer() // get the current page's dimensions + + let header-rows = validate-header-rows(header-rows) + let repeat-header = validate-repeat-header(repeat-header, header-rows: header-rows) + let header-hlines-have-priority = validate-header-hlines-priority(header-hlines-have-priority) + let map-cells = validate-map-func(map-cells) + let map-hlines = validate-map-func(map-hlines) + let map-vlines = validate-map-func(map-vlines) + let map-rows = validate-map-func(map-rows) + let map-cols = validate-map-func(map-cols) + let fit-spans = validate-fit-spans(fit-spans, default: (x: false, y: false)) + + layout(size => locate(t_loc => style(styles => { + let table_id = _tablex-table-counter.at(t_loc) + let page_dimensions = get-page-dim-state(table_id) + let page_dim_at = page_dimensions.final(t_loc) + let t_pos = t_loc.position() + + // Subtract the max width/height from current width/height to disregard margin/etc. + let page_width = size.width + let page_height = size.height + + let max_pos = default-if-none(page_dim_at.bottom_right, (x: t_pos.x + page_width, y: t_pos.y + page_height)) + let min_pos = default-if-none(page_dim_at.top_left, t_pos) + + let items = items.pos().map(table-item-convert) + + let gutter = parse-gutters( + col-gutter: column-gutter, row-gutter: row-gutter, + gutter: gutter, + styles: styles, + page-width: page_width, page-height: page_height + ) + + let validated_cols_rows = validate-cols-rows( + columns, rows, items: items.filter(is-tablex-cell)) + + let columns = validated_cols_rows.columns + let rows = validated_cols_rows.rows + items += validated_cols_rows.items + + let col_len = columns.len() + let row_len = rows.len() + + // generate cell matrix and other things + let grid_info = generate-grid( + items, + x_limit: col_len, y_limit: row_len, + map-cells: map-cells, + fit-spans: fit-spans + ) + + let table_grid = grid_info.grid + let hlines = grid_info.hlines + let vlines = grid_info.vlines + let items = grid_info.items + + // When there are more rows than the user specified, we ensure they have + // the same size as the last specified row. + let last-row-size = if rows.len() == 0 { auto } else { rows.last() } + for _ in range(grid_info.new_row_count - row_len) { + rows.push(last-row-size) // add new rows (due to extra cells) + } + + let col_len = columns.len() + let row_len = rows.len() + + let auto_lines_res = generate-autolines( + auto-lines: auto-lines, auto-hlines: auto-hlines, + auto-vlines: auto-vlines, + hlines: hlines, + vlines: vlines, + col_len: col_len, + row_len: row_len + ) + + hlines += auto_lines_res.new_hlines + vlines += auto_lines_res.new_vlines + + let parsed_lines = _parse-lines(hlines, vlines, styles: styles, page-width: page_width, page-height: page_height) + hlines = parsed_lines.hlines + vlines = parsed_lines.vlines + + let mapped_grid = apply-maps( + grid: table_grid, + hlines: hlines, + vlines: vlines, + map-hlines: map-hlines, + map-vlines: map-vlines, + map-rows: map-rows, + map-cols: map-cols + ) + + table_grid = mapped_grid.grid + hlines = mapped_grid.hlines + vlines = mapped_grid.vlines + + // re-parse just in case + let parsed_lines = _parse-lines(hlines, vlines, styles: styles, page-width: page_width, page-height: page_height) + hlines = parsed_lines.hlines + vlines = parsed_lines.vlines + + // convert auto to actual size + let updated_cols_rows = determine-auto-column-row-sizes( + grid: table_grid, + page_width: page_width, page_height: page_height, + styles: styles, + columns: columns, rows: rows, + inset: inset, align: align, + gutter: gutter, + fit-spans: fit-spans + ) + + let columns = updated_cols_rows.columns + let rows = updated_cols_rows.rows + let gutter = updated_cols_rows.gutter + + let row_groups = generate-row-groups( + grid: table_grid, + columns: columns, rows: rows, + stroke: stroke, inset: inset, + gutter: gutter, + fill: fill, align: align, + hlines: hlines, vlines: vlines, + styles: styles, + repeat-header: repeat-header, + header-hlines-have-priority: header-hlines-have-priority, + header-rows: header-rows, + rtl: rtl, + min-pos: min_pos, + max-pos: max_pos, + table-loc: t_loc, + table-id: table_id + ) + + grid(columns: (auto,), rows: auto, ..row_groups) + }))) +} + +// Same as table but defaults to lines off +#let gridx(..options) = { + tablex(auto-lines: false, ..options) +} diff --git a/packages/preview/tablex/0.0.8/typst.toml b/packages/preview/tablex/0.0.8/typst.toml new file mode 100644 index 000000000..6fde0d656 --- /dev/null +++ b/packages/preview/tablex/0.0.8/typst.toml @@ -0,0 +1,9 @@ +[package] +name = "tablex" +version = "0.0.8" +authors = ["PgBiel "] +license = "MIT" +description = "More powerful and customizable tables in Typst." +entrypoint = "tablex.typ" +repository = "https://github.com/PgBiel/typst-tablex" +keywords = ["formatting", "table", "tables", "customization"]