├── .coderunner.json ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── example-01.png ├── example-02.png ├── example-03.png ├── example-04.png ├── example-05.png ├── example-06.png ├── example-07.png ├── example-08.png ├── example-09.png ├── example-10.png ├── example-11.png ├── example-12.png ├── example-13.png ├── example-14.png ├── example-15.png ├── example-16.png ├── example-17.png └── example-18.png ├── docs ├── _doc-style.typ ├── manual.pdf ├── manual.typ └── overview.typ ├── examples ├── titanic.pdf └── titanic.typ ├── lib.typ ├── src ├── display.typ ├── helpers.typ ├── ops.typ └── tabledata.typ └── typst.toml /.coderunner.json: -------------------------------------------------------------------------------- 1 | {"examples/titanic.typ": {"python": ["Download finished\n", " Survived Pclass Name Sex Age Fare\n0 0 3 Mr. Owen Harris male 22.0 7.2500\n1 1 1 Mrs. John Bradley female 38.0 71.2833\n2 1 3 Miss. Laina Heikkinen female 26.0 7.9250\n3 1 1 Mrs. Jacques Heath female 35.0 53.1000\n4 0 3 Mr. William Henry male 35.0 8.0500\n"]}} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.csv 2 | 3 | __pycache__ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | TaDa provides a set of simple but powerful operations on rows of data. A 4 | full manual is available online: 5 | 6 | 7 | Key features include: 8 | 9 | - **Arithmetic expressions**: Row-wise operations are as simple as 10 | string expressions with field names 11 | 12 | - **Aggregation**: Any function that operates on an array of values can 13 | perform row-wise or column-wise aggregation 14 | 15 | - **Data representation**: Handle displaying currencies, floats, 16 | integers, and more with ease and arbitrary customization 17 | 18 | Note: This library is in early development. The API is subject to change 19 | especially as typst adds more support for user-defined types. 20 | **Backwards compatibility is not guaranteed!** Handling of field info, 21 | value types, and more may change substantially with more user feedback. 22 | 23 | ## Importing 24 | 25 | TaDa can be imported as follows: 26 | 27 | ### From the official packages repository (recommended): 28 | 29 | ``` typst 30 | #import "@preview/tada:0.2.0" 31 | ``` 32 | 33 | ### From the source code (not recommended) 34 | 35 | **Option 1:** You can clone the package directly into your project 36 | directory: 37 | 38 | ``` bash 39 | # In your project directory 40 | git clone https://github.com/ntjess/typst-tada.git tada 41 | ``` 42 | 43 | Then import the functionality with 44 | 45 | ``` typst 46 | #import "./tada/lib.typ" 47 | ``` 48 | 49 | **Option 2:** If Python is available on your system, use `showman` to 50 | install TaDa in typst’s `local` directory: 51 | 52 | ``` bash 53 | # Anywhere on your system 54 | git clone https://github.com/ntjess/typst-tada.git 55 | cd typst-tada 56 | 57 | # Can be done in a virtual environment 58 | pip install "git+https://github.com/ntjess/showman.git" 59 | showman package ./typst.toml 60 | ``` 61 | 62 | Now, TaDa is available under the local namespace: 63 | 64 | ``` typst 65 | #import "@local/tada:0.2.0" 66 | ``` 67 | 68 | # Table adjustment 69 | 70 | ## Creation 71 | 72 | TaDa provides three main ways to construct tables – from columns, rows, 73 | or records. 74 | 75 | - **Columns** are a dictionary of field names to column values. 76 | Alternatively, a 2D array of columns can be passed to `from-columns`, 77 | where `values.at(0)` is a column (belongs to one field). 78 | 79 | - **Records** are a 1D array of dictionaries where each dictionary is a 80 | row. 81 | 82 | - **Rows** are a 2D array where `values.at(0)` is a row (has one value 83 | for each field). Note that if `rows` are given without field names, 84 | they default to (0, 1, ..$`n`$). 85 | 86 | ``` typst 87 | #let column-data = ( 88 | name: ("Bread", "Milk", "Eggs"), 89 | price: (1.25, 2.50, 1.50), 90 | quantity: (2, 1, 3), 91 | ) 92 | #let record-data = ( 93 | (name: "Bread", price: 1.25, quantity: 2), 94 | (name: "Milk", price: 2.50, quantity: 1), 95 | (name: "Eggs", price: 1.50, quantity: 3), 96 | ) 97 | #let row-data = ( 98 | ("Bread", 1.25, 2), 99 | ("Milk", 2.50, 1), 100 | ("Eggs", 1.50, 3), 101 | ) 102 | 103 | #import tada: TableData 104 | #let td = TableData(data: column-data) 105 | // Equivalent to: 106 | #let td2 = tada.from-records(record-data) 107 | // _Not_ equivalent to (since field names are unknown): 108 | #let td3 = tada.from-rows(row-data) 109 | 110 | #to-table(td) 111 | #to-table(td2) 112 | #to-table(td3) 113 | ``` 114 | ![Example 1](https://www.github.com/ntjess/typst-tada/raw/v0.2.0/assets/example-01.png) 115 | 116 | ## Title formatting 117 | 118 | You can pass any `content` as a field’s `title`. **Note**: if you pass a 119 | string, it will be evaluated as markup. 120 | 121 | ``` typst 122 | #let fmt(it) = { 123 | heading(outlined: false, 124 | upper(it.at(0)) 125 | + it.slice(1).replace("_", " ") 126 | ) 127 | } 128 | 129 | #let titles = ( 130 | // As a function 131 | name: (title: fmt), 132 | // As a string 133 | quantity: (title: fmt("Qty")), 134 | ) 135 | #let td = TableData(..td, field-info: titles) 136 | 137 | #to-table(td) 138 | ``` 139 | ![Example 2](https://www.github.com/ntjess/typst-tada/raw/v0.2.0/assets/example-02.png) 140 | 141 | ## Adapting default behavior 142 | 143 | You can specify defaults for any field not explicitly populated by 144 | passing information to `field-defaults`. Observe in the last example 145 | that `price` was not given a title. We can indicate it should be 146 | formatted the same as `name` by passing `title: fmt` to 147 | `field-defaults`. **Note** that any field that is explicitly given a 148 | value will not be affected by `field-defaults` (i.e., `quantity` will 149 | retain its string title “Qty”) 150 | 151 | ``` typst 152 | #let defaults = (title: fmt) 153 | #let td = TableData(..td, field-defaults: defaults) 154 | #to-table(td) 155 | ``` 156 | ![Example 3](https://www.github.com/ntjess/typst-tada/raw/v0.2.0/assets/example-03.png) 157 | 158 | ## Using `__index` 159 | 160 | TaDa will automatically add an `__index` field to each row that is 161 | hidden by default. If you want it displayed, update its information to 162 | set `hide: false`: 163 | 164 | ``` typst 165 | // Use the helper function `update-fields` to update multiple fields 166 | // and/or attributes 167 | #import tada: update-fields 168 | #let td = update-fields( 169 | td, __index: (hide: false, title: "\#") 170 | ) 171 | // You can also insert attributes directly: 172 | // #td.field-info.__index.insert("hide", false) 173 | // etc. 174 | #to-table(td) 175 | ``` 176 | ![Example 4](https://www.github.com/ntjess/typst-tada/raw/v0.2.0/assets/example-04.png) 177 | 178 | ## Value formatting 179 | 180 | ### `type` 181 | 182 | Type information can have attached metadata that specifies alignment, 183 | display formats, and more. Available types and their metadata are: 184 | 185 | - **string** : (default-value: "", align: left) 186 | 187 | 188 | 189 | - **content** : (display: , align: left) 190 | 191 | 192 | 193 | - **float** : (align: right) 194 | 195 | 196 | 197 | - **integer** : (align: right) 198 | 199 | 200 | 201 | - **percent** : (display: , align: right) 202 | 203 | 204 | 205 | - **index** : (align: right) 206 | 207 | While adding your own default types is not yet supported, you can simply 208 | defined a dictionary of specifications and pass its keys to the field 209 | 210 | ``` typst 211 | #let currency-info = ( 212 | display: tada.display.format-usd, align: right 213 | ) 214 | #td.field-info.insert("price", (type: "currency")) 215 | #let td = TableData(..td, type-info: ("currency": currency-info)) 216 | #to-table(td) 217 | ``` 218 | ![Example 5](https://www.github.com/ntjess/typst-tada/raw/v0.2.0/assets/example-05.png) 219 | 220 | ## Transposing 221 | 222 | `transpose` is supported, but keep in mind if columns have different 223 | types, an error will be a frequent result. To avoid the error, 224 | explicitly pass `ignore-types: true`. You can choose whether to keep 225 | field names as an additional column by passing a string to `fields-name` 226 | that is evaluated as markup: 227 | 228 | ``` typst 229 | #to-table( 230 | tada.transpose( 231 | td, ignore-types: true, fields-name: "" 232 | ) 233 | ) 234 | ``` 235 | ![Example 6](https://www.github.com/ntjess/typst-tada/raw/v0.2.0/assets/example-06.png) 236 | 237 | ### `display` 238 | 239 | If your type is not available or you want to customize its display, pass 240 | a `display` function that formats the value, or a string that accesses 241 | `value` in its scope: 242 | 243 | ``` typst 244 | #td.field-info.at("quantity").insert( 245 | "display", 246 | val => ("/", "One", "Two", "Three").at(val), 247 | ) 248 | 249 | #let td = TableData(..td) 250 | #to-table(td) 251 | ``` 252 | ![Example 7](https://www.github.com/ntjess/typst-tada/raw/v0.2.0/assets/example-07.png) 253 | 254 | ### `align` etc. 255 | 256 | You can pass `align` and `width` to a given field’s metadata to 257 | determine how content aligns in the cell and how much horizontal space 258 | it takes up. In the future, more `table` setup arguments will be 259 | accepted. 260 | 261 | ``` typst 262 | #let adjusted = update-fields( 263 | td, name: (align: center, width: 1.4in) 264 | ) 265 | #to-table(adjusted) 266 | ``` 267 | ![Example 8](https://www.github.com/ntjess/typst-tada/raw/v0.2.0/assets/example-08.png) 268 | 269 | ## Deeper `table` customization 270 | 271 | TaDa uses `table` to display the table. So any argument that `table` 272 | accepts can be passed to TableData as well: 273 | 274 | ``` typst 275 | #let mapper = (x, y) => { 276 | if y == 0 {rgb("#8888")} else {none} 277 | } 278 | #let td = TableData( 279 | ..td, 280 | table-kwargs: ( 281 | fill: mapper, stroke: (x: none, y: black) 282 | ), 283 | ) 284 | #to-table(td) 285 | ``` 286 | ![Example 9](https://www.github.com/ntjess/typst-tada/raw/v0.2.0/assets/example-09.png) 287 | 288 | ## Subselection 289 | 290 | You can select a subset of fields or rows to display: 291 | 292 | ``` typst 293 | #import tada: subset 294 | #to-table( 295 | subset(td, indexes: (0,2), fields: ("name", "price")) 296 | ) 297 | ``` 298 | ![Example 10](https://www.github.com/ntjess/typst-tada/raw/v0.2.0/assets/example-10.png) 299 | 300 | Note that `indexes` is based on the table’s `__index` column, *not* it’s 301 | positional index within the table: 302 | 303 | ``` typst 304 | #let td2 = td 305 | #td2.data.insert("__index", (1, 2, 2)) 306 | #to-table( 307 | subset(td2, indexes: 2, fields: ("__index", "name")) 308 | ) 309 | ``` 310 | ![Example 11](https://www.github.com/ntjess/typst-tada/raw/v0.2.0/assets/example-11.png) 311 | 312 | Rows can also be selected by whether they fulfill a field condition: 313 | 314 | ``` typst 315 | #to-table( 316 | tada.filter(td, expression: "price < 1.5") 317 | ) 318 | ``` 319 | ![Example 12](https://www.github.com/ntjess/typst-tada/raw/v0.2.0/assets/example-12.png) 320 | 321 | ## Concatenation 322 | 323 | Concatenating rows and columns are both supported operations, but only 324 | in the simple sense of stacking the data. Currently, there is no ability 325 | to join on a field or otherwise intelligently merge data. 326 | 327 | - `axis: 0` places new rows below current rows 328 | 329 | - `axis: 1` places new columns to the right of current columns 330 | 331 | - Unless you specify a fill value for missing values, the function will 332 | panic if the tables do not match exactly along their concatenation 333 | axis. 334 | 335 | - You cannot stack with `axis: 1` unless every column has a unique field 336 | name. 337 | 338 | ``` typst 339 | #import tada: stack 340 | 341 | #let td2 = TableData( 342 | data: ( 343 | name: ("Cheese", "Butter"), 344 | price: (2.50, 1.75), 345 | ) 346 | ) 347 | #let td3 = TableData( 348 | data: ( 349 | rating: (4.5, 3.5, 5.0, 4.0, 2.5), 350 | ) 351 | ) 352 | 353 | // This would fail without specifying the fill 354 | // since `quantity` is missing from `td2` 355 | #let stack-a = stack(td, td2, missing-fill: 0) 356 | #let stack-b = stack(stack-a, td3, axis: 1) 357 | #to-table(stack-b) 358 | ``` 359 | ![Example 13](https://www.github.com/ntjess/typst-tada/raw/v0.2.0/assets/example-13.png) 360 | 361 | # Operations 362 | 363 | ## Expressions 364 | 365 | The easiest way to leverage TaDa’s flexibility is through expressions. 366 | They can be strings that treat field names as variables, or functions 367 | that take keyword-only arguments. 368 | 369 | - **Note**! When passing functions, every field is passed as a named 370 | argument to the function. So, make sure to capture unused fields with 371 | `..rest` (the name is unimportant) to avoid errors. 372 | 373 | ``` typst 374 | #let make-dict(field, expression) = { 375 | let out = (:) 376 | out.insert( 377 | field, 378 | (expression: expression, type: "currency"), 379 | ) 380 | out 381 | } 382 | 383 | #let td = update-fields( 384 | td, ..make-dict("total", "price * quantity" ) 385 | ) 386 | 387 | #let tax-expr(total: none, ..rest) = { total * 0.2 } 388 | #let taxed = update-fields( 389 | td, ..make-dict("tax", tax-expr), 390 | ) 391 | 392 | #to-table( 393 | subset(taxed, fields: ("name", "total", "tax")) 394 | ) 395 | ``` 396 | ![Example 14](https://www.github.com/ntjess/typst-tada/raw/v0.2.0/assets/example-14.png) 397 | 398 | ## Chaining 399 | 400 | It is inconvenient to require several temporary variables as above, or 401 | deep function nesting, to perform multiple operations on a table. TaDa 402 | provides a `chain` function to make this easier. Furthermore, when you 403 | need to compute several fields at once and don’t need extra field 404 | information, you can use `add-expressions` as a shorthand: 405 | 406 | ``` typst 407 | #import tada: chain, add-expressions 408 | #let totals = chain(td, 409 | add-expressions.with( 410 | total: "price * quantity", 411 | tax: "total * 0.2", 412 | after-tax: "total + tax", 413 | ), 414 | subset.with( 415 | fields: ("name", "total", "after-tax") 416 | ), 417 | // Add type information 418 | update-fields.with( 419 | after-tax: (type: "currency", title: fmt("w/ Tax")), 420 | ), 421 | ) 422 | #to-table(totals) 423 | ``` 424 | ![Example 15](https://www.github.com/ntjess/typst-tada/raw/v0.2.0/assets/example-15.png) 425 | 426 | ## Sorting 427 | 428 | You can sort by ascending/descending values of any field, or provide 429 | your own transformation function to the `key` argument to customize 430 | behavior further: 431 | 432 | ``` typst 433 | #import tada: sort-values 434 | #to-table(sort-values( 435 | td, by: "quantity", descending: true 436 | )) 437 | ``` 438 | ![Example 16](https://www.github.com/ntjess/typst-tada/raw/v0.2.0/assets/example-16.png) 439 | 440 | ## Aggregation 441 | 442 | Column-wise reduction is supported through `agg`, using either functions 443 | or string expressions: 444 | 445 | ``` typst 446 | #import tada: agg, item 447 | #let grand-total = chain( 448 | totals, 449 | agg.with(after-tax: array.sum), 450 | // use "item" to extract exactly one element 451 | item 452 | ) 453 | // "Output" is a helper function just for these docs. 454 | // It is not necessary in your code. 455 | #output[ 456 | *Grand total: #tada.display.format-usd(grand-total)* 457 | ] 458 | ``` 459 | ![Example 17](https://www.github.com/ntjess/typst-tada/raw/v0.2.0/assets/example-17.png) 460 | 461 | It is also easy to aggregate several expressions at once: 462 | 463 | ``` typst 464 | #let agg-exprs = ( 465 | "# items": "quantity.sum()", 466 | "Longest name": "[#name.sorted(key: str.len).at(-1)]", 467 | ) 468 | #let agg-td = tada.agg(td, ..agg-exprs) 469 | #to-table(agg-td) 470 | ``` 471 | ![Example 18](https://www.github.com/ntjess/typst-tada/raw/v0.2.0/assets/example-18.png) -------------------------------------------------------------------------------- /assets/example-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/assets/example-01.png -------------------------------------------------------------------------------- /assets/example-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/assets/example-02.png -------------------------------------------------------------------------------- /assets/example-03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/assets/example-03.png -------------------------------------------------------------------------------- /assets/example-04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/assets/example-04.png -------------------------------------------------------------------------------- /assets/example-05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/assets/example-05.png -------------------------------------------------------------------------------- /assets/example-06.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/assets/example-06.png -------------------------------------------------------------------------------- /assets/example-07.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/assets/example-07.png -------------------------------------------------------------------------------- /assets/example-08.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/assets/example-08.png -------------------------------------------------------------------------------- /assets/example-09.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/assets/example-09.png -------------------------------------------------------------------------------- /assets/example-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/assets/example-10.png -------------------------------------------------------------------------------- /assets/example-11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/assets/example-11.png -------------------------------------------------------------------------------- /assets/example-12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/assets/example-12.png -------------------------------------------------------------------------------- /assets/example-13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/assets/example-13.png -------------------------------------------------------------------------------- /assets/example-14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/assets/example-14.png -------------------------------------------------------------------------------- /assets/example-15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/assets/example-15.png -------------------------------------------------------------------------------- /assets/example-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/assets/example-16.png -------------------------------------------------------------------------------- /assets/example-17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/assets/example-17.png -------------------------------------------------------------------------------- /assets/example-18.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/assets/example-18.png -------------------------------------------------------------------------------- /docs/_doc-style.typ: -------------------------------------------------------------------------------- 1 | #import "@preview/tidy:0.1.0" 2 | #import tidy.styles.default: * 3 | 4 | #let show-parameter-block( 5 | name, types, content, style-args, 6 | show-default: false, 7 | default: none, 8 | ) = block( 9 | inset: 10pt, fill: rgb("ddd3"), width: 100%, 10 | breakable: style-args.break-param-descriptions, 11 | [ 12 | #text(weight: "bold", size: 1.1em, name) 13 | #h(.5cm) 14 | #types.map(x => (style-args.style.show-type)(x)).join([ #text("or",size:.6em) ]) 15 | 16 | #content 17 | #if show-default [ #parbreak() Default: #raw(lang: "typc", default) ] 18 | ] 19 | ) 20 | 21 | #let type-colors = ( 22 | "content": rgb("#a6ebe699"), 23 | "color": rgb("#a6ebe699"), 24 | "string": rgb("#d1ffe299"), 25 | "none": rgb("#ffcbc499"), 26 | "auto": rgb("#ffcbc499"), 27 | "boolean": rgb("#ffedc199"), 28 | "integer": rgb("#e7d9ff99"), 29 | "float": rgb("#e7d9ff99"), 30 | "ratio": rgb("#e7d9ff99"), 31 | "length": rgb("#e7d9ff99"), 32 | "angle": rgb("#e7d9ff99"), 33 | "relative-length": rgb("#e7d9ff99"), 34 | "fraction": rgb("#e7d9ff99"), 35 | "symbol": rgb("#eff0f399"), 36 | "array": rgb("#eff0f399"), 37 | "dictionary": rgb("#eff0f399"), 38 | "arguments": rgb("#eff0f399"), 39 | "selector": rgb("#eff0f399"), 40 | "module": rgb("#eff0f399"), 41 | "stroke": rgb("#eff0f399"), 42 | "function": rgb("#f9dfff99"), 43 | ) 44 | #let get-type-color(type) = type-colors.at(type, default: rgb("#eff0f333")) 45 | 46 | // Create beautiful, colored type box 47 | #let show-type(type) = { 48 | h(2pt) 49 | box(outset: 2pt, fill: get-type-color(type), radius: 2pt, raw(type)) 50 | h(2pt) 51 | } -------------------------------------------------------------------------------- /docs/manual.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/docs/manual.pdf -------------------------------------------------------------------------------- /docs/manual.typ: -------------------------------------------------------------------------------- 1 | #import "@preview/tidy:0.4.0" 2 | #import "../lib.typ" as tada 3 | #import "_doc-style.typ" 4 | // https://github.com/ntjess/showman.git 5 | #import "@preview/showman:0.1.2": formatter 6 | 7 | #let _HEADING-LEVEL = 1 8 | 9 | #show raw.where(lang: "example"): it => { 10 | heading(level: _HEADING-LEVEL + 2)[Example] 11 | it 12 | } 13 | #outline(indent: 1em, depth: _HEADING-LEVEL + 1) 14 | 15 | #include "./overview.typ" 16 | 17 | #for file in ("tabledata", "ops", "display") { 18 | let module = tidy.parse-module( 19 | read("../src/" + file + ".typ"), 20 | scope: ( 21 | tada: tada, 22 | ..dictionary(tada.display), 23 | ..dictionary(tada.helpers), 24 | ..dictionary(tada), 25 | ), 26 | ) 27 | heading[Functions in #raw(file + ".typ", block: false)] 28 | tidy.show-module(module, first-heading-level: _HEADING-LEVEL, show-outline: false) 29 | } 30 | -------------------------------------------------------------------------------- /docs/overview.typ: -------------------------------------------------------------------------------- 1 | #import "../lib.typ" 2 | 3 | // Use "let =" instead of "import as" to be compatible with pandoc 3.1.10 4 | #let tada = lib 5 | #let DEFAULT-TYPE-FORMATS = tada.display.DEFAULT-TYPE-FORMATS 6 | 7 | #let template = doc => doc 8 | #if "typst-hs-version" not in sys.inputs { 9 | import "@preview/showman:0.1.2": formatter 10 | template = formatter.template.with( 11 | // theme: "dark ", 12 | eval-kwargs: ( 13 | direction: ltr, 14 | scope: (tada: tada), 15 | eval-prefix: "#let to-table(it) = output(tada.to-table(it))", 16 | ), 17 | ) 18 | } 19 | 20 | #show: template 21 | #show : set text(font: "Libertinus serif") 22 | 23 | = Overview 24 | TaDa provides a set of simple but powerful operations on rows of data. A full manual is 25 | available online: #link( 26 | "https://github.com/ntjess/typst-tada/blob/v" + str(tada.tada-version) + "/docs/manual.pdf" 27 | ) 28 | 29 | Key features include: 30 | 31 | - *Arithmetic expressions*: Row-wise operations are as simple as string expressions with field names 32 | 33 | - *Aggregation*: Any function that operates on an array of values can perform row-wise or 34 | column-wise aggregation 35 | 36 | - *Data representation*: Handle displaying currencies, floats, integers, and more 37 | with ease and arbitrary customization 38 | 39 | #text(red)[Note: This library is in early development. The API is subject to change especially as typst adds more support for user-defined types. *Backwards compatibility is not guaranteed!* Handling of field info, value types, and more may change substantially with more user feedback.] 40 | 41 | // Leave out for now 42 | // #show outline.entry: it => { 43 | // link(it.element.location(), it.body) 44 | // } 45 | // #show outline.entry.where( 46 | // level: 1 47 | // ): it => { 48 | // v(12pt, weak: true) 49 | // strong(it) 50 | // } 51 | 52 | #let _make-import-stmt(namespace) = { 53 | let import-str = "#import \"@" + namespace + "/tada:" + str(tada.tada-version) + "\"" 54 | raw(lang: "typst", block: true, import-str) 55 | } 56 | 57 | // #outline(indent: 1em, fill: none, title: none) 58 | == Importing 59 | TaDa can be imported as follows: 60 | 61 | === From the official packages repository (recommended): 62 | #_make-import-stmt("preview") 63 | 64 | === From the source code (not recommended) 65 | *Option 1:* You can clone the package directly into your project directory: 66 | ```bash 67 | # In your project directory 68 | git clone https://github.com/ntjess/typst-tada.git tada 69 | ``` 70 | Then import the functionality with 71 | ```typst #import "./tada/lib.typ" ``` 72 | 73 | *Option 2:* If Python is available on your system, 74 | use `showman` to install TaDa in typst's `local` directory: 75 | ```bash 76 | # Anywhere on your system 77 | git clone https://github.com/ntjess/typst-tada.git 78 | cd typst-tada 79 | 80 | # Can be done in a virtual environment 81 | pip install "git+https://github.com/ntjess/showman.git" 82 | showman package ./typst.toml 83 | ``` 84 | Now, TaDa is available under the local namespace: 85 | 86 | #_make-import-stmt("local") 87 | 88 | 89 | = Table adjustment 90 | == Creation 91 | TaDa provides three main ways to construct tables -- from columns, rows, or records. 92 | - *Columns* are a dictionary of field names to column values. Alternatively, a 2D array 93 | of columns can be passed to `from-columns`, where `values.at(0)` is a column (belongs 94 | to one field). 95 | - *Records* are a 1D array of dictionaries where each dictionary is a row. 96 | - *Rows* are a 2D array where `values.at(0)` is a row (has one value for each field). 97 | Note that if `rows` are given without field names, they default to (0, 1, ..$n$). 98 | 99 | ```globalexample 100 | #let column-data = ( 101 | name: ("Bread", "Milk", "Eggs"), 102 | price: (1.25, 2.50, 1.50), 103 | quantity: (2, 1, 3), 104 | ) 105 | #let record-data = ( 106 | (name: "Bread", price: 1.25, quantity: 2), 107 | (name: "Milk", price: 2.50, quantity: 1), 108 | (name: "Eggs", price: 1.50, quantity: 3), 109 | ) 110 | #let row-data = ( 111 | ("Bread", 1.25, 2), 112 | ("Milk", 2.50, 1), 113 | ("Eggs", 1.50, 3), 114 | ) 115 | 116 | #import tada: TableData 117 | #let td = TableData(data: column-data) 118 | // Equivalent to: 119 | #let td2 = tada.from-records(record-data) 120 | // _Not_ equivalent to (since field names are unknown): 121 | #let td3 = tada.from-rows(row-data) 122 | 123 | #to-table(td) 124 | #to-table(td2) 125 | #to-table(td3) 126 | ``` 127 | 128 | == Title formatting 129 | You can pass any `content` as a field's `title`. *Note*: if you pass a string, it will be evaluated as markup. 130 | 131 | ```globalexample 132 | #let fmt(it) = { 133 | heading(outlined: false, 134 | upper(it.at(0)) 135 | + it.slice(1).replace("_", " ") 136 | ) 137 | } 138 | 139 | #let titles = ( 140 | // As a function 141 | name: (title: fmt), 142 | // As a string 143 | quantity: (title: fmt("Qty")), 144 | ) 145 | #let td = TableData(..td, field-info: titles) 146 | 147 | #to-table(td) 148 | ``` 149 | 150 | == Adapting default behavior 151 | You can specify defaults for any field not explicitly populated by passing information to 152 | `field-defaults`. Observe in the last example that `price` was not given a title. We can 153 | indicate it should be formatted the same as `name` by passing `title: fmt` to `field-defaults`. *Note* that any field that is explicitly given a value will not be affected by `field-defaults` (i.e., `quantity` will retain its string title "Qty") 154 | 155 | ```globalexample 156 | #let defaults = (title: fmt) 157 | #let td = TableData(..td, field-defaults: defaults) 158 | #to-table(td) 159 | ``` 160 | 161 | == Using `__index` 162 | 163 | TaDa will automatically add an `__index` field to each row that is hidden by default. If you want it displayed, update its information to set `hide: false`: 164 | 165 | ```globalexample 166 | // Use the helper function `update-fields` to update multiple fields 167 | // and/or attributes 168 | #import tada: update-fields 169 | #let td = update-fields( 170 | td, __index: (hide: false, title: "\#") 171 | ) 172 | // You can also insert attributes directly: 173 | // #td.field-info.__index.insert("hide", false) 174 | // etc. 175 | #to-table(td) 176 | ``` 177 | 178 | == Value formatting 179 | 180 | === `type` 181 | Type information can have attached metadata that specifies alignment, display formats, and more. Available types and their metadata are: 182 | #for (typ, info) in DEFAULT-TYPE-FORMATS [ 183 | - *#typ* : #info 184 | ] 185 | 186 | While adding your own default types is not yet supported, you can simply defined 187 | a dictionary of specifications and pass its keys to the field 188 | 189 | ```globalexample 190 | #let currency-info = ( 191 | display: tada.display.format-usd, align: right 192 | ) 193 | #td.field-info.insert("price", (type: "currency")) 194 | #let td = TableData(..td, type-info: ("currency": currency-info)) 195 | #to-table(td) 196 | ``` 197 | 198 | == Transposing 199 | `transpose` is supported, but keep in mind if columns have different types, an error will be a frequent result. To avoid the error, explicitly pass `ignore-types: true`. You can choose whether to keep field names as an additional column by passing a string to `fields-name` that is evaluated as markup: 200 | 201 | ```globalexample 202 | #to-table( 203 | tada.transpose( 204 | td, ignore-types: true, fields-name: "" 205 | ) 206 | ) 207 | ``` 208 | 209 | // Leave this out until locales are handled more robustly 210 | // === Currency and decimal locales 211 | // You can account for your locale by updating `default-currency`, `default-hundreds-separator`, and `default-decimal`: 212 | // ```globalexample 213 | // #to-table[ 214 | // American: #format-currency(12.5) 215 | 216 | // ] 217 | // #default-currency.update("€") 218 | // #to-table[ 219 | // European: #format-currency(12.5) 220 | // ] 221 | // ``` 222 | 223 | // These changes will also impact how `currency` and `float` types are displayed in a table. 224 | 225 | === `display` 226 | If your type is not available or you want to customize its display, pass a `display` function that formats the value, or a string that accesses `value` in its scope: 227 | 228 | ```globalexample 229 | #td.field-info.at("quantity").insert( 230 | "display", 231 | val => ("/", "One", "Two", "Three").at(val), 232 | ) 233 | 234 | #let td = TableData(..td) 235 | #to-table(td) 236 | ``` 237 | 238 | === `align` etc. 239 | You can pass `align` and `width` to a given field's metadata to determine how content aligns in the cell and how much horizontal space it takes up. In the future, more `table` setup arguments will be accepted. 240 | 241 | ```globalexample 242 | #let adjusted = update-fields( 243 | td, name: (align: center, width: 1.4in) 244 | ) 245 | #to-table(adjusted) 246 | ``` 247 | 248 | == Deeper `table` customization 249 | TaDa uses `table` to display the table. So any argument that `table` accepts can be 250 | passed to TableData as well: 251 | 252 | ```globalexample 253 | #let mapper = (x, y) => { 254 | if y == 0 {rgb("#8888")} else {none} 255 | } 256 | #let td = TableData( 257 | ..td, 258 | table-kwargs: ( 259 | fill: mapper, stroke: (x: none, y: black) 260 | ), 261 | ) 262 | #to-table(td) 263 | ``` 264 | 265 | == Subselection 266 | You can select a subset of fields or rows to display: 267 | 268 | ```globalexample 269 | #import tada: subset 270 | #to-table( 271 | subset(td, indexes: (0,2), fields: ("name", "price")) 272 | ) 273 | ``` 274 | 275 | Note that `indexes` is based on the table's `__index` column, _not_ it's positional index within the table: 276 | ```globalexample 277 | #let td2 = td 278 | #td2.data.insert("__index", (1, 2, 2)) 279 | #to-table( 280 | subset(td2, indexes: 2, fields: ("__index", "name")) 281 | ) 282 | ``` 283 | 284 | Rows can also be selected by whether they fulfill a field condition: 285 | ```globalexample 286 | #to-table( 287 | tada.filter(td, expression: "price < 1.5") 288 | ) 289 | ``` 290 | 291 | == Concatenation 292 | Concatenating rows and columns are both supported operations, but only in the simple sense of stacking the data. Currently, there is no ability to join on a field or otherwise intelligently merge data. 293 | - `axis: 0` places new rows below current rows 294 | - `axis: 1` places new columns to the right of current columns 295 | - Unless you specify a fill value for missing values, the function will panic if the tables do not match exactly along their concatenation axis. 296 | - You cannot stack with `axis: 1` unless every column has a unique field name. 297 | 298 | ```globalexample 299 | #import tada: stack 300 | 301 | #let td2 = TableData( 302 | data: ( 303 | name: ("Cheese", "Butter"), 304 | price: (2.50, 1.75), 305 | ) 306 | ) 307 | #let td3 = TableData( 308 | data: ( 309 | rating: (4.5, 3.5, 5.0, 4.0, 2.5), 310 | ) 311 | ) 312 | 313 | // This would fail without specifying the fill 314 | // since `quantity` is missing from `td2` 315 | #let stack-a = stack(td, td2, missing-fill: 0) 316 | #let stack-b = stack(stack-a, td3, axis: 1) 317 | #to-table(stack-b) 318 | ``` 319 | 320 | = Operations 321 | 322 | == Expressions 323 | The easiest way to leverage TaDa's flexibility is through expressions. They can be strings that treat field names as variables, or functions that take keyword-only arguments. 324 | - *Note*! When passing functions, every field is passed as a named argument to the function. So, make sure to capture unused fields with `..rest` (the name is unimportant) to avoid errors. 325 | 326 | ```globalexample 327 | #let make-dict(field, expression) = { 328 | let out = (:) 329 | out.insert( 330 | field, 331 | (expression: expression, type: "currency"), 332 | ) 333 | out 334 | } 335 | 336 | #let td = update-fields( 337 | td, ..make-dict("total", "price * quantity" ) 338 | ) 339 | 340 | #let tax-expr(total: none, ..rest) = { total * 0.2 } 341 | #let taxed = update-fields( 342 | td, ..make-dict("tax", tax-expr), 343 | ) 344 | 345 | #to-table( 346 | subset(taxed, fields: ("name", "total", "tax")) 347 | ) 348 | ``` 349 | 350 | == Chaining 351 | It is inconvenient to require several temporary variables as above, or deep function nesting, to perform multiple operations on a table. TaDa provides a `chain` function to make this easier. Furthermore, when you need to compute several fields at once and don't need extra field information, you can use `add-expressions` as a shorthand: 352 | 353 | ```globalexample 354 | #import tada: chain, add-expressions 355 | #let totals = chain(td, 356 | add-expressions.with( 357 | total: "price * quantity", 358 | tax: "total * 0.2", 359 | after-tax: "total + tax", 360 | ), 361 | subset.with( 362 | fields: ("name", "total", "after-tax") 363 | ), 364 | // Add type information 365 | update-fields.with( 366 | after-tax: (type: "currency", title: fmt("w/ Tax")), 367 | ), 368 | ) 369 | #to-table(totals) 370 | ``` 371 | 372 | == Sorting 373 | You can sort by ascending/descending values of any field, or provide your own transformation function to the `key` argument to customize behavior further: 374 | ```globalexample 375 | #import tada: sort-values 376 | #to-table(sort-values( 377 | td, by: "quantity", descending: true 378 | )) 379 | ``` 380 | 381 | == Aggregation 382 | Column-wise reduction is supported through `agg`, using either functions or string expressions: 383 | 384 | ```globalexample 385 | #import tada: agg, item 386 | #let grand-total = chain( 387 | totals, 388 | agg.with(after-tax: array.sum), 389 | // use "item" to extract exactly one element 390 | item 391 | ) 392 | // "Output" is a helper function just for these docs. 393 | // It is not necessary in your code. 394 | #output[ 395 | *Grand total: #tada.display.format-usd(grand-total)* 396 | ] 397 | ``` 398 | 399 | It is also easy to aggregate several expressions at once: 400 | ```globalexample 401 | #let agg-exprs = ( 402 | "# items": "quantity.sum()", 403 | "Longest name": "[#name.sorted(key: str.len).at(-1)]", 404 | ) 405 | #let agg-td = tada.agg(td, ..agg-exprs) 406 | #to-table(agg-td) 407 | ``` 408 | -------------------------------------------------------------------------------- /examples/titanic.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ntjess/typst-tada/40fbc0a7dabb9c89ed269fb8086a84d90f4b98d3/examples/titanic.pdf -------------------------------------------------------------------------------- /examples/titanic.typ: -------------------------------------------------------------------------------- 1 | 2 | // https://github.com/ntjess/showman.git 3 | #import "@preview/showman:0.1.2": runner, formatter 4 | 5 | #import "../lib.typ" as tada 6 | 7 | // redefine to ensure path is read here instead of from showman executor 8 | #let local-csv(path) = csv(path) 9 | #let template = formatter.template.with( 10 | // theme: "dark", 11 | eval-kwargs: ( 12 | scope: (tada: tada, csv: local-csv), 13 | eval-prefix: "#let to-table(it) = output(tada.to-table(it))", 14 | direction: ltr, 15 | ), 16 | ) 17 | #show: template 18 | 19 | #let cache = json("/.coderunner.json").at("examples/titanic.typ", default: (:)) 20 | #show raw.where(lang: "python"): runner.external-code.with(result-cache: cache, direction: ttb) 21 | #show : set text(font: "Libertinus serif") 22 | 23 | #set page(margin: 0.7in, height: auto) 24 | 25 | = Poking around the `titanic` dataset 26 | == First in Python 27 | 28 | ```python 29 | import requests 30 | from pathlib import Path 31 | 32 | def download(url, output_file): 33 | if not Path(output_file).exists(): 34 | r = requests.get(url) 35 | with open(output_file, "wb") as f: 36 | f.write(r.content) 37 | print("Download finished") 38 | 39 | download("https://web.stanford.edu/class/archive/cs/cs109/cs109.1166/stuff/titanic.csv", "examples/titanic.csv") 40 | ``` 41 | 42 | ```python 43 | import pandas as pd 44 | df = pd.read_csv("examples/titanic.csv") 45 | df["Name"] = df["Name"].str.split(" ").str.slice(0, 3).str.join(" ") 46 | df = df.drop(df.filter(like="Aboard", axis=1).columns, axis=1) 47 | 48 | print(df.head(5)) 49 | ``` 50 | 51 | = Can we do it in Typst? 52 | 53 | ```globalexample 54 | #let csv-to-tabledata(file, n-rows: -2) = { 55 | let data = csv(file) 56 | let headers = data.at(0) 57 | let rows = data.slice(1, n-rows + 1) 58 | tada.from-rows(rows, field-info: headers) 59 | } 60 | #import tada: TableData, subset, chain, filter, update-fields, agg, sort-values 61 | #let td = chain( 62 | csv-to-tabledata("/examples/titanic.csv"), 63 | // Shorten long names 64 | tada.add-expressions.with( 65 | Name: `Name.split(" ").slice(1, 3).join(" ")`, 66 | ), 67 | ) 68 | #output[ 69 | Data loaded! 70 | #chain( 71 | td, 72 | subset.with( 73 | fields: ("Name", "Age", "Fare"), indexes: range(3) 74 | ), 75 | to-table 76 | ) 77 | ] 78 | ``` 79 | 80 | == Make it prettier 81 | ```globalexample 82 | #let fill(x, y) = { 83 | let fill = none 84 | if y == 0 { 85 | fill = rgb("#8888") 86 | } else if calc.odd(y) { 87 | fill = rgb("#1ea3f288") 88 | } 89 | fill 90 | } 91 | #let title-fmt(name) = heading(outlined: false, name) 92 | #td.table-kwargs.insert("fill", fill) 93 | #td.field-defaults.insert("title", title-fmt) 94 | #to-table(subset(td, fields: ("Name", "Age", "Fare"), indexes: range(0, 5))) 95 | ``` 96 | 97 | == Convert types & clean data 98 | ```globalexample 99 | #let usd = tada.display.format-usd 100 | 101 | #let td = chain( 102 | td, 103 | tada.add-expressions.with( 104 | Pclass: `int(Pclass)`, 105 | Name: `Name.slice(0, Name.position("("))`, 106 | Sex: `upper(Sex.at(0))`, 107 | Age: `float(Age)`, 108 | Fare: `float(Fare)`, 109 | ), 110 | update-fields.with( 111 | Fare: (display: usd), 112 | ), 113 | subset.with( 114 | fields: ("Pclass", "Name", "Age", "Fare") 115 | ), 116 | sort-values.with(by: "Fare", descending: true), 117 | ) 118 | #to-table(subset(td, indexes: range(0, 10))) 119 | ``` 120 | 121 | == Find just the passengers over 30 paying over \$230 122 | ```globalexample 123 | #to-table(filter(td, expression: `Age > 30 and Fare > 230`)) 124 | ``` 125 | 126 | == See how much each class paid and their average age 127 | ```globalexample 128 | #let fares-per-class = tada.group-by( 129 | td, 130 | by: "Pclass", 131 | aggs: ( 132 | "Total Fare": `Fare.sum()`, 133 | "Avg Age": `int(Age.sum()/Age.len())`, 134 | ), 135 | field-info: ("Total Fare": (display: usd)), 136 | ) 137 | #to-table(fares-per-class) 138 | ``` 139 | -------------------------------------------------------------------------------- /lib.typ: -------------------------------------------------------------------------------- 1 | #import "src/ops.typ" 2 | #import "src/tabledata.typ" 3 | #import "src/display.typ" 4 | #import "src/helpers.typ" 5 | 6 | #import display: to-table 7 | #import tabledata: ( 8 | add-expressions, 9 | count, 10 | drop, 11 | from-columns, 12 | from-rows, 13 | from-records, 14 | item, 15 | stack, 16 | subset, 17 | TableData, 18 | transpose, 19 | update-fields, 20 | ) 21 | #import ops: agg, chain, filter, group-by, sort-values 22 | 23 | #let tada-version = toml("typst.toml").package.version 24 | -------------------------------------------------------------------------------- /src/display.typ: -------------------------------------------------------------------------------- 1 | #import "helpers.typ" as H 2 | 3 | #let default-hundreds-separator = state("separator-state", ",") 4 | #let default-decimal = state("decimal-state", ".") 5 | 6 | /// Converts a float to a string where the comma, decimal, and precision can be customized. 7 | /// 8 | /// ```example 9 | /// #format-float(123456, precision: 2, pad: true)\ 10 | /// #format-float(123456.1121, precision: 1, hundreds-separator: "_") 11 | /// ``` 12 | /// 13 | /// -> str 14 | #let format-float( 15 | /// The number to convert -> number 16 | number, 17 | /// The character to use to separate hundreds -> str | auto 18 | hundreds-separator: auto, 19 | /// The character to use to separate the integer and fractional portions -> str | auto 20 | decimal: auto, 21 | /// The number of digits to show after the decimal point. If `none`, then no rounding will be done. 22 | /// -> int | none 23 | precision: none, 24 | /// If true, then the fractional portion will be padded with zeros to match the precision if needed. 25 | /// -> bool 26 | pad: false, 27 | ) = { 28 | // Adds commas after each 3 digits to make 29 | // pricing more readable 30 | if hundreds-separator == auto { 31 | hundreds-separator = default-hundreds-separator.display() 32 | } 33 | if precision != none { 34 | number = calc.round(number, digits: precision) 35 | } 36 | if decimal == auto { 37 | decimal = default-decimal.display() 38 | } 39 | 40 | // negative != hyphen, so grab from unicode 41 | let negative-sign = str.from-unicode(0x2212) 42 | let sign = if number < 0 { negative-sign } else { "" } 43 | let number-pieces = str(number).split(".") 44 | let integer-portion = number-pieces.at(0).trim(negative-sign) 45 | let num-with-commas = "" 46 | for ii in range(integer-portion.len()) { 47 | if calc.rem(ii, 3) == 0 and ii > 0 { 48 | num-with-commas = hundreds-separator + num-with-commas 49 | } 50 | num-with-commas = integer-portion.at(-ii - 1) + num-with-commas 51 | } 52 | 53 | let frac-portion = if number-pieces.len() > 1 { 54 | number-pieces.at(1) 55 | } else { 56 | "" 57 | } 58 | if precision != none and pad { 59 | for _ in range(precision - frac-portion.len()) { 60 | frac-portion = frac-portion + "0" 61 | } 62 | } 63 | 64 | if frac-portion != "" { 65 | num-with-commas = num-with-commas + decimal + frac-portion 66 | } 67 | sign + num-with-commas 68 | } 69 | 70 | /// Converts a float to a United States dollar amount. 71 | /// 72 | /// ```example 73 | /// #format-usd(12.323)\ 74 | /// #format-usd(-12500.29) 75 | /// ``` 76 | /// -> str 77 | #let format-usd( 78 | /// The number to convert -> float | int 79 | number, 80 | /// Passed to @format-float() -> any 81 | ..args, 82 | ) = { 83 | // "negative" sign if needed 84 | if args.pos().len() > 0 { 85 | panic("format-usd() does not accept positional arguments") 86 | } 87 | let sign = if number < 0 { str.from-unicode(0x2212) } else { "" } 88 | let currency = "$" 89 | [#sign#currency] 90 | let named = (precision: 2, pad: true, ..args.named()) 91 | format-float(calc.abs(number), ..named) 92 | } 93 | 94 | 95 | #let format-percent(number, ..args) = { 96 | format-float(number * 100, ..args) + "%" 97 | } 98 | 99 | #let format-content(value) = { 100 | if type(value) == str { 101 | value = eval(value, mode: "markup") 102 | } 103 | value 104 | } 105 | 106 | #let DEFAULT-TYPE-FORMATS = ( 107 | string: (default-value: "", align: left), 108 | content: (display: format-content, align: left), 109 | float: (align: right), 110 | integer: (align: right), 111 | percent: (display: format-percent, align: right), 112 | // TODO: Better country-robust currency 113 | // currency: (display: format-currency, align: right), 114 | index: (align: right), 115 | ) 116 | 117 | 118 | #let _value-to-display(value, value-info) = { 119 | if value == none { 120 | // TODO: Allow customizing `none` representation 121 | return value 122 | } 123 | if "display" in value-info { 124 | let scope = value-info.at("scope", default: (:)) + (value: value) 125 | H.eval-str-or-function(value-info.display, scope: scope, positional: value) 126 | } else { 127 | value 128 | } 129 | } 130 | 131 | #let title-case(field) = field.replace("-", " ").split(" ").map(word => upper(word.at(0)) + word.slice(1)).join(" ") 132 | 133 | 134 | #let _field-info-to-table-kwargs(field-info) = { 135 | let get-eval(dict, key, default) = { 136 | let value = dict.at(key, default: default) 137 | if type(value) == "string" { 138 | eval(value) 139 | } else { 140 | value 141 | } 142 | } 143 | 144 | let (names, aligns, widths) = ((), (), ()) 145 | for (key, info) in field-info.pairs() { 146 | if "title" in info { 147 | let original-field = key 148 | let info-scope = info.at("scope", default: (:)) 149 | let scope = (..info-scope, field: original-field, title-case-field: title-case(original-field)) 150 | key = H.eval-str-or-function(info.at("title"), scope: scope, positional: original-field) 151 | if type(key) not in (str, content) { 152 | key = repr(key) 153 | } 154 | } 155 | names.push(key) 156 | let default-align = if info.at("type", default: none) == "string" { left } else { right } 157 | aligns.push(get-eval(info, "align", default-align)) 158 | widths.push(get-eval(info, "width", auto)) 159 | } 160 | // Keys correspond to table specs other than "names" which is positional 161 | (names: names, align: aligns, columns: widths) 162 | } 163 | 164 | 165 | /// Converts a @TableData into a displayed `table`. This is the main (and only intended) 166 | /// way of rendering `tada` data. Most keywords can be overridden for customizing the 167 | /// output. 168 | /// 169 | /// ```example 170 | /// #let td = TableData( 171 | /// data: (a: (1, 2), b: (3, 4)), 172 | /// // Tables can carry their own kwargs, too 173 | /// table-kwargs: (inset: (x: 3em, y: 0.5em)) 174 | /// ) 175 | /// #to-table(td, fill: red) 176 | /// ``` 177 | /// -> content 178 | #let to-table( 179 | /// The data to render -> TableData 180 | td, 181 | /// Passed to `table` -> any 182 | ..kwargs, 183 | ) = { 184 | let (field-info, type-info) = (td.field-info, td.type-info) 185 | // Order by field specification 186 | let to-show = field-info.keys().filter(key => not field-info.at(key).at("hide", default: false)) 187 | let subset = H.keep-keys(td.data, keys: to-show) 188 | // Make sure field info matches data order 189 | field-info = H.keep-keys(field-info, keys: subset.keys(), reorder: true) 190 | let display-columns = subset 191 | .pairs() 192 | .map(key-column => { 193 | let (key, column) = key-column 194 | column.map(value => _value-to-display(value, field-info.at(key))) 195 | }) 196 | let rows = H.transpose-values(display-columns) 197 | 198 | let col-spec = _field-info-to-table-kwargs(field-info) 199 | let names = col-spec.remove("names") 200 | // We don't want a completely flattened array, since some cells may contain many values. 201 | // So use sum() to join rows together instead 202 | table(..td.table-kwargs, ..col-spec, ..kwargs, ..names, ..rows.sum().map(x => [#x])) 203 | } 204 | -------------------------------------------------------------------------------- /src/helpers.typ: -------------------------------------------------------------------------------- 1 | #let keep-keys(dict, keys: (), reorder: false) = { 2 | let out = (:) 3 | if not reorder { 4 | // Keep original insertion order 5 | keys = dict.keys().filter(key => key in keys) 6 | } 7 | for key in keys.filter(key => key in dict) { 8 | out.insert(key, dict.at(key)) 9 | } 10 | out 11 | } 12 | 13 | #let is-numeric-type(typ) = { 14 | typ in (int, float) 15 | } 16 | 17 | #let unique-record-keys(rows) = { 18 | rows.map(row => row.keys()).sum(default: ()).dedup() 19 | } 20 | 21 | #let is-internal-field(key) = { 22 | key.starts-with("__") 23 | } 24 | 25 | #let is-external-field(key) = not is-internal-field(key) 26 | 27 | #let remove-internal-fields(data) = { 28 | let out = (:) 29 | for key in data.keys().filter(is-external-field) { 30 | out.insert(key, data.at(key)) 31 | } 32 | out 33 | } 34 | 35 | #let dict-from-pairs(pairs) = { 36 | let out = (:) 37 | for (key, value) in pairs { 38 | out.insert(key, value) 39 | } 40 | out 41 | } 42 | 43 | #let default-dict(keys, value: none) = { 44 | dict-from-pairs(keys.map(key => (key, value))) 45 | } 46 | 47 | #let merge-nested-dicts(a, b, recurse: false) = { 48 | if recurse { 49 | panic("Recursive merging not implemented yet") 50 | } 51 | let merged = (:) 52 | for (key, val) in a { 53 | if type(val) == dictionary and type(b.at(key, default: none)) == dictionary { 54 | val += b.at(key) 55 | } else { 56 | val = b.at(key, default: val) 57 | } 58 | merged.insert(key, val) 59 | } 60 | for key in b.keys().filter(key => key not in a) { 61 | merged.insert(key, b.at(key)) 62 | } 63 | merged 64 | } 65 | 66 | #let transpose-values(values) = { 67 | let out = () 68 | for (ii, row) in values.enumerate() { 69 | for (jj, value) in row.enumerate() { 70 | if ii == 0 { 71 | out.push(()) 72 | } 73 | out.at(jj).push(value) 74 | } 75 | } 76 | out 77 | } 78 | 79 | #let assert-is-type(value, allowed-type, value-name) = { 80 | let value-type = type(value) 81 | assert( 82 | value-type == allowed-type, 83 | message: "`" + value-name + "` must be type " + repr(allowed-type) + ", got: " + value-type 84 | ) 85 | } 86 | 87 | #let assert-list-of-type(values, allowed-type, value-name) = { 88 | let iterator = if type(values) == dictionary { 89 | values 90 | } else if type(values) == array { 91 | values.enumerate() 92 | } else { 93 | panic("Expected a list or dictionary for " + value-name + ", got: " + type(values)) 94 | } 95 | for (index, value) in iterator { 96 | assert-is-type(value, allowed-type, value-name + ".at(" + repr(index) + ")") 97 | } 98 | } 99 | 100 | #let assert-rectangular-matrix(values) = { 101 | assert-is-type(values, array, "values") 102 | if values.len() == 0 { 103 | return 104 | } 105 | assert-list-of-type(values, array, "values") 106 | let row-lengths = values.map(row => row.len()) 107 | assert( 108 | row-lengths.dedup().len() == 1, 109 | message: "Expected a rectangular 2D matrix, got lengths: " + repr(row-lengths) 110 | ) 111 | } 112 | 113 | #let eval-str-or-function( 114 | func-or-str, 115 | mode: "markup", 116 | scope: (:), 117 | default-if-auto: (arg) => arg, 118 | positional: (), 119 | keyword: (:), 120 | ) = { 121 | if type(positional) != array { 122 | positional = (positional, ) 123 | } 124 | if func-or-str == auto { 125 | func-or-str = default-if-auto 126 | } 127 | let typ = type(func-or-str) 128 | if typ == function { 129 | return func-or-str(..positional, ..keyword) 130 | } 131 | if typ == content and func-or-str.has("text") { 132 | func-or-str = func-or-str.text 133 | } else if typ == content { 134 | return func-or-str 135 | } 136 | if type(func-or-str) != str { 137 | panic("Expected a function, string, or raw content, got " + typ + ": " + repr(func-or-str)) 138 | } 139 | return eval(func-or-str, mode: mode, scope: scope) 140 | } -------------------------------------------------------------------------------- /src/ops.typ: -------------------------------------------------------------------------------- 1 | #import "./tabledata.typ": TableData, add-expressions, subset, update-fields, stack 2 | #import "./helpers.typ" as H 3 | 4 | /// Performs an aggregation across entire data columns. 5 | /// 6 | /// ```example 7 | /// #let td = TableData(data: (a: (1, 2, 3), b: (4, 5, 6))) 8 | /// #to-table(agg(td, a: array.sum, b-average: "b.sum() / b.len()")) 9 | /// ``` 10 | /// -> TableData 11 | #let agg( 12 | /// The table to aggregate 13 | /// -> TableData 14 | td, 15 | /// Optional overrides to the initial table's field info. This is useful in case an 16 | /// aggregation function changes the field's type or needs a new display function. 17 | /// -> dictionary 18 | field-info: (:), 19 | /// A mapping of field names to aggregation functions or expressions. Expects a function 20 | /// accepting named arguments, one for each field in the table. The return value will be 21 | /// placed in a single cell. 22 | /// - #text(red)[*Note*!] If the assigned name for a function matches an existing field, 23 | /// _and_ a function (not a string) is passed, the behavior changes: Instead, the 24 | /// function must take one _positional_ argument and only receives values for the field it's assigned to. For instance, in a table with a field `price`, you can easily calculate the total price by calling `agg(td, price: array.sum)`. If this behavior was not enabled, this would be `agg(td, price: (price: none, ..rest) => price.sum()`. 25 | /// - Columns will have their missing (`none`) values removed before being passed to the 26 | /// function or expression. 27 | /// -> dictionary 28 | ..field-func-map, 29 | ) = { 30 | let named = field-func-map.named() 31 | let values = (:) 32 | let cleaned-data = H.dict-from-pairs(td.data.keys().zip(td.data.values().map(arr => arr.filter(v => v != none)))) 33 | for (field, func) in named { 34 | let result = none 35 | if type(func) == function and field in td.data { 36 | // Special behavior described in docstring 37 | result = func(td.data.at(field)) 38 | } else { 39 | result = H.eval-str-or-function( 40 | func, 41 | mode: "code", 42 | scope: cleaned-data, 43 | keyword: cleaned-data, 44 | ) 45 | } 46 | // Agg results are treated as one value, so even if they return multiple outputs, 47 | // it will be considered a nested array. 48 | values.insert(field, (result,)) 49 | } 50 | let valid-fields = td.field-info.keys().filter(field => field in named) 51 | let valid-field-info = H.keep-keys(td.field-info, keys: valid-fields) 52 | TableData( 53 | ..td, 54 | field-info: H.merge-nested-dicts(valid-field-info, field-info), 55 | data: values, 56 | ) 57 | } 58 | 59 | /// Sequentially applies a list of table operations to a given table. 60 | /// 61 | /// The operations can be any function that takes a TableData 62 | /// object as its first argument. It is recommended when applying many transformations 63 | /// in a row, since it avoids the need for deeply nesting operations or keeping many 64 | /// temporary variables. 65 | /// 66 | /// Returns a TableData object that results from applying all the operations in sequence. 67 | /// 68 | /// ```example 69 | /// #let td = TableData(data: (a: (1, 2, 3), b: (4, 5, 6))) 70 | /// #to-table(chain(td, 71 | /// filter.with(expression: "a > 1"), 72 | /// sort-values.with(by: "b", descending: true) 73 | /// )) 74 | /// ``` 75 | /// -> TableData 76 | #let chain( 77 | /// The initial table to which the operations will be applied. 78 | /// -> TableData 79 | td, 80 | /// A list of table operations. Each operation is applied to the table in sequence. 81 | /// Operations must be compatible with TableData. 82 | /// -> TableData 83 | ..operations, 84 | ) = { 85 | for op in operations.pos() { 86 | if type(op) == array { 87 | td = op.at(0)(td, ..op.slice(1)) 88 | } else { 89 | td = op(td) 90 | } 91 | } 92 | td 93 | } 94 | 95 | /// Filters rows in a table based on a given expression, returning a new TableData object 96 | /// containing only the rows for which the expression evaluates to true. This function filters 97 | /// rows in the table based on a boolean expression. The expression is evaluated for each row, 98 | /// and only rows for which the expression evaluates to true are retained in the output table. 99 | /// 100 | /// ```example 101 | /// #let td = TableData(data: (a: (1, 2, 3), b: (4, 5, 6))) 102 | /// #to-table(filter(td, expression: "a > 1 and b > 5")) 103 | /// ``` 104 | /// -> TableData 105 | #let filter( 106 | /// The table to filter. 107 | /// -> TableData 108 | td, 109 | /// A boolean expression used to filter rows. The expression can reference fields in the 110 | /// table and must result in a truthy output. 111 | /// -> string 112 | expression: none, 113 | ) = { 114 | let mask = add-expressions(td, __filter: expression).data.__filter 115 | let out-data = (:) 116 | for key in td.data.keys() { 117 | out-data.insert( 118 | key, 119 | td.data.at(key).zip(mask).filter(val-mask => val-mask.at(1)).map(val-mask => val-mask.at(0)), 120 | ) 121 | } 122 | TableData(..td, data: out-data) 123 | } 124 | 125 | 126 | /// Sorts the rows of a table based on the values of a specified field, returning a new 127 | /// TableData object with rows sorted based on the specified field. 128 | /// -> TableData 129 | #let sort-values( 130 | /// The table to sort -> TableData 131 | td, 132 | /// The field name to sort by -> string 133 | by: none, 134 | /// A function that transforms the values of the field before sorting. Defaults to the 135 | /// identity function if not provided. 136 | /// -> function 137 | key: values => values, 138 | /// Specifies whether to sort in descending order. Defaults to false for ascending order. 139 | /// -> bool 140 | descending: false, 141 | ) = { 142 | if by == none { 143 | panic("`sort()` requires a field name to sort by") 144 | } 145 | let values-and-indexes = td.data.at(by).map(key).enumerate() 146 | let indexes = values-and-indexes.sorted(key: idx-vals => idx-vals.at(1)).map(idx-vals => idx-vals.at(0)) 147 | if descending { 148 | indexes = indexes.rev() 149 | } 150 | let sorted-data = (:) 151 | for (key, column) in td.data { 152 | column = indexes.map(idx => column.at(idx)) 153 | sorted-data.insert(key, column) 154 | } 155 | TableData(..td, data: sorted-data) 156 | } 157 | 158 | /// Creates a list of (value, group-table) pairs, one for each unique value in the 159 | /// given field. This list is optionally condensed into one table using specified 160 | /// aggregation functions. 161 | /// 162 | /// ```example 163 | /// #let td = TableData(data: ( 164 | /// a: (1, 1, 1, 2, 3, 3), 165 | /// b: (4, 5, 6, 7, 8, 9), 166 | /// c: (10, 11, 12, 13, 14, 15) 167 | /// )) 168 | /// #let first-group = group-by(td, by: "a").at(0) 169 | /// Group identity: #repr(first-group.at(0)) 170 | /// #to-table(first-group.at(1)) 171 | /// Aggregated: 172 | /// #to-table(group-by(td, by: "a", aggs: (count: "a.len()"))) 173 | /// ``` 174 | /// -> array | TableData 175 | #let group-by( 176 | /// The table to group -> TableData 177 | td, 178 | /// The field whose values are used for grouping -> string 179 | by: none, 180 | /// A dictionary of aggregations to apply to each group. The keys are the field names 181 | /// for the resulting table, and the values are the aggregation functions or expressions. 182 | /// -> dictionary 183 | aggs: (:), 184 | /// Optional overrides to the initial table's field info. This is useful in case an 185 | /// aggregation function changes the field's type or needs a new display function. 186 | /// -> dictionary 187 | field-info: (:), 188 | ) = { 189 | let groups = td.data.at(by).dedup() 190 | let group-agg = groups.map(group-value => { 191 | let filtered = filter(td, expression: by + " == " + repr(group-value)) 192 | if aggs.len() == 0 { 193 | return filtered 194 | } 195 | let agg-td = agg(filtered, ..aggs, field-info: field-info) 196 | let cur-group-info = td.field-info.at(by) + (values: (group-value,)) 197 | let updated-field = (:) 198 | updated-field.insert(by, cur-group-info) 199 | // Take a subset to ensure group comes first 200 | return chain( 201 | agg-td, 202 | update-fields.with(..updated-field), 203 | subset.with(fields: (by, ..aggs.keys())), 204 | ) 205 | }) 206 | if aggs.len() > 0 { 207 | let dummy-data = H.default-dict((by,) + aggs.keys(), value: ()) 208 | let initial = TableData(data: dummy-data) 209 | return group-agg.fold(initial, stack) 210 | } else { 211 | return groups.zip(group-agg) 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/tabledata.typ: -------------------------------------------------------------------------------- 1 | #import "helpers.typ" as H 2 | #import "display.typ": DEFAULT-TYPE-FORMATS 3 | 4 | 5 | #let _get-n-rows(data) = { 6 | if data.values().len() == 0 { 0 } else { data.values().at(0).len() } 7 | } 8 | 9 | #let _data-to-records(data) = { 10 | let values = data.values() 11 | let records = range(_get-n-rows(data)).map(ii => { 12 | let row-values = values.map(arr => arr.at(ii)) 13 | H.dict-from-pairs(data.keys().zip(row-values)) 14 | }) 15 | records 16 | } 17 | 18 | #let _eval-expressions(data, field-info) = { 19 | let computed-fields = field-info.keys().filter(key => "expression" in field-info.at(key)) 20 | if computed-fields.len() == 0 { 21 | return (data, field-info) 22 | } 23 | 24 | // new data = (a: (), b: (), ...) 25 | // new values will be pushed to each array as they are computed 26 | let out-data = data + H.default-dict(computed-fields, value: ()) 27 | let records = _data-to-records(data) 28 | for row in records { 29 | for key in computed-fields { 30 | let scope = row 31 | // Populate unspecified fields with default values 32 | for (key, info) in field-info.pairs() { 33 | if key not in row and "default" in info { 34 | scope.insert(key, info.at("default")) 35 | } 36 | } 37 | let expr = field-info.at(key).at("expression") 38 | let default-scope = field-info.at(key).at("scope", default: (:)) 39 | let value = H.eval-str-or-function( 40 | expr, 41 | scope: default-scope + scope, 42 | mode: "code", 43 | keyword: scope, 44 | ) 45 | out-data.at(key).push(value) 46 | // In case this field is referenced by another expression 47 | row.insert(key, value) 48 | } 49 | } 50 | // Expressions are now evaluated, discard them so they aren't re-evaluated when 51 | // constructing a followup table 52 | for key in computed-fields { 53 | let _ = field-info.at(key).remove("expression") 54 | } 55 | (out-data, field-info) 56 | } 57 | 58 | #let _infer-field-type(field, values) = { 59 | if values.len() == 0 { 60 | return "content" 61 | } 62 | let types = values.map(value => type(value)).dedup() 63 | if types.len() > 1 and type(none) in types { 64 | types = types.filter(typ => typ != type(none)) 65 | } 66 | if types.len() > 1 { 67 | panic("Field `" + field + "` has multiple types: " + repr(types)) 68 | } 69 | repr(types.at(0)) 70 | } 71 | 72 | #let _resolve-field-info(field-info, field-defaults, type-info, data) = { 73 | // Add required, internal fields 74 | field-info = (__index: (hide: true, type: "index")) + field-info 75 | 76 | // Add fields that only appear in data, but weren't specified by the user otherwise 77 | for field in data.keys() { 78 | if field not in field-info { 79 | field-info.insert(field, (:)) 80 | } 81 | } 82 | 83 | // Now that we have the comprehensive field list, add default properties that aren't 84 | // specified, and properties attached to the type 85 | for (field, existing-info) in field-info { 86 | // Take any "values" passed and give them directly to data 87 | if "values" in existing-info { 88 | data.insert(field, existing-info.remove("values")) 89 | } 90 | let type-str = existing-info.at("type", default: field-defaults.at("type", default: auto)) 91 | if type-str == auto { 92 | type-str = _infer-field-type(field, data.at(field)) 93 | } 94 | let type-info = DEFAULT-TYPE-FORMATS + type-info 95 | let defaults-for-field = field-defaults + type-info.at(type-str, default: (:)) 96 | for key in defaults-for-field.keys() { 97 | if key not in existing-info { 98 | existing-info.insert(key, defaults-for-field.at(key)) 99 | } 100 | } 101 | field-info.insert(field, existing-info) 102 | } 103 | 104 | // Not allowed to have any fields not in the data 105 | let extra-fields = field-info.keys().filter(key => key not in data) 106 | if extra-fields.len() > 0 { 107 | panic("`field-info` contained fields not in data: " + repr(extra-fields)) 108 | } 109 | (field-info, data) 110 | } 111 | 112 | #let _validate-td-args(data, field-info, type-info, field-defaults, table-kwargs) = { 113 | // dict of lists 114 | // (field: (a, b, c), field2: (5, 10, 15), ...) 115 | H.assert-is-type(data, dictionary, "data") 116 | H.assert-rectangular-matrix(data.values()) 117 | 118 | // dict of dicts 119 | // (field: (type: "integer"), field2: (display: "#text(red, value)"), ...) 120 | H.assert-list-of-type(field-info, dictionary, "field-info") 121 | 122 | // dict of dicts 123 | // (currency: (display: format-usd), percent: (display: format-percent), ...) 124 | H.assert-is-type(type-info, dictionary, "type-info") 125 | H.assert-list-of-type(type-info, dictionary, "type-info") 126 | 127 | // dict of values 128 | // (type: integer, title: #field-title-case, ...) 129 | H.assert-is-type(field-defaults, dictionary, "field-defaults") 130 | // dict of values 131 | // (auto-vlines: false, map-rows: () => {}, ...) 132 | H.assert-is-type(table-kwargs, dictionary, "table-kwargs") 133 | } 134 | 135 | /// Constructs a @TableData object from a dictionary of columnar data. See examples in 136 | /// the overview above for metadata examples. 137 | /// -> TableData 138 | #let TableData( 139 | /// A dictionary of arrays, each representing a column of data. Every column must have 140 | /// the same length. Missing values are represented by `none`. 141 | /// -> dictionary 142 | data: none, 143 | /// A dictionary of dictionaries, each representing the properties of a field. The keys 144 | /// of the outer dictionary must match the keys of `data`. The keys of the inner 145 | /// dictionaries are all optional and can contain: 146 | /// A dictionary of dictionaries, each representing the properties of a field. The keys 147 | /// of the outer dictionary must match the keys of `data`. The keys of the inner 148 | /// dictionaries are all optional and can contain: 149 | /// - `type` (string): The type of the field. Must be one of the keys of `type-info`. 150 | /// Defaults to `auto`, which will attempt to infer the type from the data. 151 | /// - `title` (string): The title of the field. Defaults to the field name, title-cased. 152 | /// - `display` (string): The display format of the field. Defaults to the display format 153 | /// for the field's type. 154 | /// - `expression` (string, function): A string or function containing a Python expression that will be evaluated 155 | /// for each row to compute the value of the field. The expression can reference any 156 | /// other field in the table by name. 157 | /// - `hide` (boolean): Whether to hide the field from the table. Defaults to `false`. 158 | /// -> dictionary 159 | field-info: (:), 160 | /// A dictionary of dictionaries, each representing the properties of a type. These 161 | /// properties will be populated for a field if its type is given in `field-info` and the 162 | /// property is not specified already. 163 | /// -> dictionary 164 | type-info: (:), 165 | /// Default values for every field if not specified in `field-info`. 166 | /// -> dictionary 167 | field-defaults: (:), 168 | /// Keyword arguments to pass to `table()`. -> dictionary 169 | table-kwargs: (:), 170 | /// Reserved for future use; currently discarded. 171 | ..reserved, 172 | ) = { 173 | if reserved.pos().len() > 0 { 174 | panic("TableData() doesn't accept positional arguments") 175 | } 176 | _validate-td-args(data, field-info, type-info, field-defaults, table-kwargs) 177 | let n-rows = _get-n-rows(data) 178 | let initial-index = data.at("__index", default: range(_get-n-rows(data))) 179 | let index = initial-index 180 | .enumerate() 181 | .map(idx-val => { 182 | let (ii, value) = idx-val 183 | if value == none { 184 | value = ii 185 | } 186 | value 187 | }) 188 | // Preserve ordering if the user specified an index, otherwise put at the front 189 | if "__index" in data { 190 | data.__index = index 191 | } else { 192 | data = (__index: index, ..data) 193 | } 194 | 195 | (data, field-info) = _eval-expressions(data, field-info) 196 | (field-info, data) = _resolve-field-info(field-info, field-defaults, type-info, data) 197 | 198 | ( 199 | data: data, 200 | field-info: field-info, 201 | type-info: type-info, 202 | field-defaults: field-defaults, 203 | table-kwargs: table-kwargs, 204 | ) 205 | } 206 | 207 | #let _resolve-row-col-ctor-field-info(field-info, n-columns) = { 208 | if field-info == auto { 209 | field-info = range(n-columns).map(str) 210 | } 211 | if type(field-info) == array { 212 | H.assert-list-of-type(field-info, str, "field-info") 213 | field-info = H.default-dict(field-info, value: (:)) 214 | } 215 | return field-info 216 | } 217 | 218 | /// Constructs a @TableData object from a list of column-oriented data and their field info. 219 | /// 220 | /// ```example 221 | /// #let data = ( 222 | /// (1, 2, 3), 223 | /// (4, 5, 6), 224 | /// ) 225 | /// #let mk-tbl(..args) = to-table(from-columns(..args)) 226 | /// #set align(center) 227 | /// #grid(columns: 2, column-gutter: 1em)[ 228 | /// Auto names: 229 | /// #mk-tbl(data) 230 | /// ][ 231 | /// User names: 232 | /// #mk-tbl(data, field-info: ("a", "b")) 233 | /// ] 234 | /// ``` 235 | /// -> TableData 236 | #let from-columns( 237 | /// A list of arrays, each representing a column of data. Every column must have the 238 | /// same length and columns.len() must match field-info.keys().len() 239 | /// -> array 240 | columns, 241 | /// See the `field-info` argument to @TableData for handling dictionary types. If an 242 | /// array is passed, it is converted to a dictionary of (key1: (:), ...). 243 | /// -> dictionary 244 | field-info: auto, 245 | /// Forwarded directly to @TableData -> dictionary 246 | ..metadata, 247 | ) = { 248 | if metadata.pos().len() > 0 { 249 | panic("from-columns() only accepts one positional argument") 250 | } 251 | field-info = _resolve-row-col-ctor-field-info(field-info, columns.len()) 252 | if field-info.keys().len() != columns.len() { 253 | panic( 254 | "When creating a TableData from rows or columns, the number of fields must match " 255 | + "the number of columns, got: " 256 | + repr(field-info.keys()) 257 | + " fields and " 258 | + repr(columns.len()) 259 | + " columns", 260 | ) 261 | } 262 | let data = H.dict-from-pairs(field-info.keys().zip(columns)) 263 | TableData(data: data, field-info: field-info, ..metadata) 264 | } 265 | 266 | /// Constructs a @TableData object from a list of row-oriented data and their field info. 267 | /// 268 | /// ```example 269 | /// #let data = ( 270 | /// (1, 2, 3), 271 | /// (4, 5, 6), 272 | /// ) 273 | /// #to-table(from-rows(data, field-info: ("a", "b", "c"))) 274 | /// ``` 275 | /// -> TableData 276 | #let from-rows( 277 | /// A list of arrays, each representing a row of data. Every row must have the same 278 | /// length and rows.at(0).len() must match field-info.keys().len() 279 | /// -> array 280 | rows, 281 | /// See the `field-info` to @from-columns() -> dictionary 282 | field-info: auto, 283 | /// Forwarded directly to @TableData -> dictionary 284 | ..metadata, 285 | ) = { 286 | from-columns(H.transpose-values(rows), field-info: field-info, ..metadata) 287 | } 288 | 289 | 290 | /// Constructs a @TableData object from a list of records. 291 | /// 292 | /// A record is a dictionary of key-value pairs, Records may contain different keys, in 293 | /// which case the resulting @TableData will contain the union of all keys present with 294 | /// `none` values for missing keys. 295 | /// 296 | /// ```example 297 | /// #let records = ( 298 | /// (a: 1, b: 2), 299 | /// (a: 3, c: 4), 300 | /// ) 301 | /// #to-table(from-records(records)) 302 | /// ``` 303 | /// -> TableData 304 | #let from-records( 305 | /// A list of dictionaries, each representing a record. Every record must have the same 306 | /// keys. 307 | /// -> array 308 | records, 309 | /// Forwarded directly to @TableData -> dictionary 310 | ..metadata, 311 | ) = { 312 | H.assert-is-type(records, array, "records") 313 | H.assert-list-of-type(records, dictionary, "records") 314 | let encountered-keys = H.unique-record-keys(records) 315 | let data = H.default-dict(encountered-keys, value: ()) 316 | for record in records { 317 | for key in encountered-keys { 318 | data.at(key).push(record.at(key, default: none)) 319 | } 320 | } 321 | TableData(data: data, ..metadata) 322 | } 323 | 324 | /// Extracts a single value from a @TableData that has exactly one field and one row. 325 | /// 326 | /// ```example 327 | /// #let td = TableData(data: (a: (1,))) 328 | /// #item(td) 329 | /// ``` 330 | /// -> any 331 | #let item( 332 | /// The table to extract a value from -> TableData 333 | td, 334 | ) = { 335 | let filtered = H.remove-internal-fields(td.data) 336 | if filtered.keys().len() != 1 { 337 | panic("TableData must have exactly one field to call .item(), got: " + repr(td.data.keys())) 338 | } 339 | let values = filtered.values().at(0) 340 | if values.len() != 1 { 341 | panic("TableData must have exactly one row to call .item(), got: " + repr(values.len())) 342 | } 343 | values.at(0) 344 | } 345 | 346 | /// Creates a new @TableData with only the specified fields and/or indexes. 347 | /// 348 | /// ```example 349 | /// #let td = TableData(data: (a: (1, 2), b: (3, 4), c: (5, 6))) 350 | /// #to-table(subset(td, fields: ("a", "c"), indexes: (0,))) 351 | /// ``` 352 | /// -> TableData 353 | #let subset( 354 | /// The table to subset -> TableData 355 | td, 356 | /// The field or fields to keep. If `auto`, all fields are kept -> array | str | auto 357 | indexes: auto, 358 | /// The index or indexes to keep. If `auto`, all indexes are kept -> array | int | auto 359 | fields: auto, 360 | ) = { 361 | let (data, field-info) = (td.data, td.field-info) 362 | if type(indexes) == int { 363 | indexes = (indexes,) 364 | } 365 | if type(fields) == str { 366 | fields = (fields,) 367 | } 368 | // "__index" may be removed below, so save a copy for index filtering if needed 369 | let index = data.__index 370 | if fields != auto { 371 | data = H.keep-keys(data, keys: fields) 372 | field-info = H.keep-keys(field-info, keys: fields) 373 | } 374 | if indexes != auto { 375 | let keep-mask = index.map(ii => ii in indexes) 376 | let out = (:) 377 | for (field, values) in data { 378 | out.insert(field, values.zip(keep-mask).filter(pair => pair.at(1)).map(pair => pair.at(0))) 379 | } 380 | data = out 381 | } 382 | return TableData(..td, data: data, field-info: field-info) 383 | } 384 | 385 | /// Similar to @subset, but drops the specified fields and/or indexes instead of 386 | /// keeping them. 387 | /// 388 | /// ```example 389 | /// #let td = TableData(data: (a: (1, 2), b: (3, 4), c: (5, 6))) 390 | /// #to-table(drop(td, fields: ("a", "c"), indexes: (0,))) 391 | /// ``` 392 | /// -> TableData 393 | #let drop( 394 | /// The table to drop fields from -> TableData 395 | td, 396 | /// The field or fields to drop. If `auto`, no fields are dropped -> array | str | auto 397 | fields: none, 398 | /// The index or indexes to drop. If `auto`, no indexes are dropped -> array | int | auto 399 | indexes: none, 400 | ) = { 401 | let keep-keys = auto 402 | if fields != none { 403 | if type(fields) == str { 404 | fields = (fields,) 405 | } 406 | keep-keys = td.data.keys().filter(key => key not in fields) 407 | } 408 | let keep-indexes = auto 409 | if indexes != none { 410 | if type(indexes) == int { 411 | indexes = (indexes,) 412 | } 413 | keep-indexes = td.data.__index.filter(ii => ii not in indexes) 414 | } 415 | subset(td, fields: keep-keys, indexes: keep-indexes) 416 | } 417 | 418 | /// Converts rows into columns, discards field info, and uses `__index` as the new fields. 419 | /// 420 | /// ```example 421 | /// #let td = TableData(data: (a: (1, 2), b: (3, 4), c: (5, 6))) 422 | /// #to-table(transpose(td)) 423 | /// ``` 424 | /// -> TableData 425 | #let transpose( 426 | /// - td (TableData): The table to transpose 427 | td, 428 | /// The name of the field containing the new field names. If `none`, the new fields 429 | /// are named `0`, `1`, etc. 430 | /// -> str | none 431 | fields-name: none, 432 | /// Whether to ignore the types of the original table and instead use `content` for 433 | /// all fields. This is useful when not all columns have the same type, since a warning 434 | /// will occur when multiple types are encountered in the same field otherwise. 435 | /// -> bool 436 | ignore-types: false, 437 | /// Forwarded directly to @TableData -> dictionary 438 | ..metadata, 439 | ) = { 440 | let new-keys = td.data.at("__index").map(str) 441 | let filtered = H.remove-internal-fields(td.data) 442 | let new-values = H.transpose-values(filtered.values()) 443 | let data = H.dict-from-pairs(new-keys.zip(new-values)) 444 | let info = (:) 445 | if ignore-types { 446 | info = H.default-dict(data.keys(), value: (type: "content")) 447 | } 448 | if fields-name != none { 449 | let (new-data, new-info) = ((:), (:)) 450 | new-data.insert(fields-name, filtered.keys()) 451 | new-info.insert(fields-name, (:)) 452 | data = new-data + data 453 | info = new-info + info 454 | } 455 | // None of the initial kwargs make sense: types, display info, etc. 456 | // since the transposed table has no relation to the original. 457 | // Therefore, don't forward old `td` info 458 | TableData(data: data, field-info: info, ..metadata) 459 | } 460 | 461 | #let _ensure-a-data-has-b-fields(td-a, td-b, a-name, b-name, missing-fill) = { 462 | let (a, b) = (td-a.data, td-b.data) 463 | let missing-fields = b.keys().filter(key => key not in a) 464 | if missing-fields.len() > 0 and missing-fill == auto { 465 | panic( 466 | "No fill value was specified, yet `" 467 | + a-name 468 | + "` contains fields not in `" 469 | + b-name 470 | + "`: " 471 | + repr(missing-fields), 472 | ) 473 | } 474 | let fill-arr = (missing-fill,) * _get-n-rows(a) 475 | a = a + H.default-dict(missing-fields, value: fill-arr) 476 | a 477 | } 478 | 479 | #let _merge-infos(a, b, exclude: ("data",)) = { 480 | let merged = H.merge-nested-dicts(a, b) 481 | for key in exclude { 482 | let _ = merged.remove(key, default: none) 483 | } 484 | merged 485 | } 486 | 487 | #let _stack-rows(td, other, missing-fill: auto) = { 488 | let data = _ensure-a-data-has-b-fields(td, other, "td", "other", missing-fill) 489 | let other-data = _ensure-a-data-has-b-fields(other, td, "other", "td", missing-fill) 490 | // TODO: allow customizing how metadata gets merged. For now, `other` wins but keep 491 | // both 492 | let merged-info = _merge-infos(td, other) 493 | 494 | let merged-data = (:) 495 | for key in data.keys() { 496 | merged-data.insert(key, data.at(key) + other-data.at(key)) 497 | } 498 | TableData(data: merged-data, ..merged-info) 499 | } 500 | 501 | #let _ensure-a-has-at-least-b-rows(td-a, td-b, a-name, b-name, missing-fill: auto) = { 502 | let (a, b) = (td-a.data, td-b.data) 503 | let (a-rows, b-rows) = (_get-n-rows(a), _get-n-rows(b)) 504 | if _get-n-rows(a) < _get-n-rows(b) { 505 | panic( 506 | "No fill value was specified, yet `" 507 | + a-name 508 | + "` has fewer rows than `" 509 | + b-name 510 | + "`: " 511 | + repr(a-rows) 512 | + " vs " 513 | + repr(b-rows), 514 | ) 515 | } 516 | let pad-arr = (missing-fill,) * (b-rows - a-rows) 517 | for key in a.keys() { 518 | a.insert(key, a.at(key) + pad-arr) 519 | } 520 | a 521 | } 522 | 523 | #let _stack-columns(td, other, missing-fill: auto) = { 524 | other.data = H.remove-internal-fields(other.data) 525 | let overlapping-fields = td.data.keys().filter(key => key in other.data) 526 | if overlapping-fields.len() > 0 { 527 | panic( 528 | "Can't stack `td` and `other` column-wise because they have overlapping fields: " 529 | + repr(overlapping-fields) 530 | + ". Either remove or rename these fields before stacking.", 531 | ) 532 | } 533 | let data = _ensure-a-has-at-least-b-rows(td, other, "td", "other", missing-fill: missing-fill) 534 | let other-data = _ensure-a-has-at-least-b-rows(other, td, "other", "td", missing-fill: missing-fill) 535 | let merged-data = data + other-data 536 | 537 | let merged-info = _merge-infos(td, other) 538 | TableData(data: merged-data, ..merged-info) 539 | } 540 | 541 | /// Stacks two tables on top of or next to each other. 542 | /// 543 | /// ```example 544 | /// #let td = TableData(data: (a: (1, 2), b: (3, 4))) 545 | /// #let other = TableData(data: (c: (7, 8), d: (9, 10))) 546 | /// #grid(columns: 2, column-gutter: 1em)[ 547 | /// #to-table(stack(td, other, axis: 1)) 548 | /// ][ 549 | /// #to-table(stack( 550 | /// td, other, axis: 0, missing-fill: -4 551 | /// )) 552 | /// ] 553 | /// ``` 554 | /// 555 | /// - td (TableData): The table to stack on 556 | /// - other (TableData): The table to stack 557 | /// - axis (int): The axis to stack on. 0 will place `other` below `td`, 1 will place 558 | /// `other` to the right of `td`. If `missing-fill` is not specified, either the 559 | /// number of rows or fields must match exactly along the chosen axis. 560 | /// - #text(red)[*Note*!] If `axis` is 1, `other` may not have any field names that are 561 | /// already in `td`. 562 | /// - missing-fill (any): The value to use for missing fields or rows. If `auto`, an 563 | /// error will be raised if the number of rows or fields don't match exactly along the 564 | /// chosen axis. 565 | /// -> TableData 566 | #let stack(td, other, axis: 0, missing-fill: auto) = { 567 | if axis == 0 { 568 | _stack-rows(td, other, missing-fill: missing-fill) 569 | } else if axis == 1 { 570 | _stack-columns(td, other, missing-fill: missing-fill) 571 | } else { 572 | panic("Invalid axis: " + repr(axis)) 573 | } 574 | } 575 | 576 | #let update-fields(td, replace: false, ..field-info) = { 577 | let field-info = field-info.named() 578 | if not replace { 579 | field-info = H.merge-nested-dicts(td.field-info, field-info) 580 | } 581 | TableData(..td, field-info: field-info) 582 | } 583 | 584 | /// Shorthand to easily compute expressions on a table. 585 | /// 586 | /// ```example 587 | /// #let td = TableData(data: (a: (1, 2, 3))) 588 | /// #to-table(add-expressions(td, b: `a + 1`)) 589 | /// ``` 590 | #let add-expressions( 591 | /// The table to add expressions to -> TableData 592 | td, 593 | /// An array of expressions to compute. 594 | /// - Positional arguments are converted to (`value`: (expression: `value`)) 595 | /// - Named arguments are converted to (`key`: (expression: `value`)) 596 | /// -> any 597 | ..expressions, 598 | ) = { 599 | let info = (:) 600 | for expr in expressions.pos() { 601 | info.insert(expr, (expression: expr)) 602 | } 603 | for (field, expr) in expressions.named() { 604 | info.insert(field, (expression: expr)) 605 | } 606 | update-fields(td, ..info) 607 | } 608 | 609 | /// Returns a @TableData with a single `count` column and one value -- the number of 610 | /// rows in the table. 611 | /// 612 | /// ```example 613 | /// #let td = TableData(data: (a: (1, 2, 3), b: (3, 4, none))) 614 | /// #to-table(count(td)) 615 | /// ``` 616 | /// -> TableData 617 | #let count( 618 | /// The table to count -> TableData 619 | td, 620 | ) = { 621 | TableData( 622 | ..td, 623 | data: (count: (_get-n-rows(td.data),)), 624 | // Erase field info, but types and defaults are still, valid 625 | field-info: (:), 626 | ) 627 | } 628 | -------------------------------------------------------------------------------- /typst.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tada" 3 | version = "0.2.0" 4 | entrypoint = "lib.typ" 5 | authors = ["Nathan Jessurun"] 6 | license = "Unlicense" 7 | description = "Easy, composable tabular data manipulation" 8 | repository = "https://github.com/ntjess/typst-tada" 9 | keywords = [ 10 | "record", 11 | "dataframe", 12 | "table", 13 | "tabular", 14 | "agg", 15 | "aggregate", 16 | "filter", 17 | "pandas", 18 | "polars", 19 | ] 20 | exclude = ["manual.pdf"] 21 | 22 | 23 | [tool.packager] 24 | paths = [ 25 | "src", 26 | "lib.typ", 27 | "LICENSE", 28 | "README.md", 29 | { from = "docs/manual.pdf", to = "manual.pdf" }, 30 | ] 31 | --------------------------------------------------------------------------------