├── .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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------