diff --git a/packages/preview/taskize/0.2.8/LICENSE b/packages/preview/taskize/0.2.8/LICENSE new file mode 100644 index 0000000000..364e20571e --- /dev/null +++ b/packages/preview/taskize/0.2.8/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Nathan Scheinmann + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/preview/taskize/0.2.8/README.md b/packages/preview/taskize/0.2.8/README.md new file mode 100644 index 0000000000..f4dd9c38da --- /dev/null +++ b/packages/preview/taskize/0.2.8/README.md @@ -0,0 +1,609 @@ +# taskize + +[![taskize on Typst Universe](https://img.shields.io/badge/Typst_Universe-v._0.2.8-239dad?labelColor=eee)](https://typst.app/universe/package/taskize) +[![Full package manual as PDF](https://img.shields.io/badge/Manual-pdf-333333?labelColor=eee)](https://github.com/nathan-ed/typst-package-taskize/blob/cb28f846af4b83863be59166129b8a754e70d2fb/docs/manual.pdf) +[![Distributed under the MIT license](https://img.shields.io/badge/License-MIT-333333?labelColor=eee)](LICENSE) + +A Typst package for creating horizontal columned lists, similar to LaTeX's `tasks` package. Perfect for exercises, multiple-choice questions, and any content that benefits from a compact columned layout. + +## Gallery + +Click on an image to see the source code. + +| | | | | +|:---:|:---:|:---:|:---:| +| [![Basic two-column task list with alphabetic labels](gallery/basic.svg)](gallery/basic.typ) | [![Math exercises with inline equations in a task grid](gallery/math.svg)](gallery/math.typ) | [![Custom label formats: uppercase, numeric, parenthesized](gallery/labels.svg)](gallery/labels.typ) | [![Styled layout with custom colors and bold labels](gallery/styled.svg)](gallery/styled.typ) | +| Basic Tasks | Math Exercises | Custom Labels | Styled Layout | +| [![Vertical flow direction filling columns top-to-bottom](gallery/vertical.svg)](gallery/vertical.typ) | [![Column spanning: items spanning two or all columns](gallery/span.svg)](gallery/span.typ) | [![Bold label weight for emphasis in task lists](gallery/bold.svg)](gallery/bold.typ) | [![Resume numbering continuing across multiple task blocks](gallery/resume.svg)](gallery/resume.typ) | +| Vertical Flow | Column Spanning | Bold Labels | Resume Numbering | +| [![Adaptive row heights: display fractions bleeding into the row gutter, then fixed](gallery/adaptive.svg)](gallery/adaptive.typ) | [![Wrap zone reserving a top-right corner for a QR code while task rows flow around it](gallery/wrap-zone.svg)](gallery/wrap-zone.typ) | | | +| Adaptive Row Heights | Wrap Zone | | | + +## Features + +- **Indentation-independent parsing** - Output is completely independent of source code indentation +- **Horizontal flow layout** - Items flow left-to-right across columns (like LaTeX's tasks) +- **Flexible column counts** - 2, 3, 4 or any number of columns +- **Auto-fit columns** - Use the largest column count that does not introduce a new wrap or overflow +- **Column spanning** - Items can span multiple columns using `(N)` syntax +- **Multiple label formats** - Alphabetic (`a)`, `A)`), numeric (`1)`, `(1)`), roman (`i)`, `I)`), bullets, or custom +- **Bold labels** - Make labels stand out with bold weight +- **Resume numbering** - Continue numbering across multiple task blocks +- **Two flow directions** - Horizontal (a b | c d) or vertical (a c | b d) +- **Global configuration** - Set defaults for all tasks in your document +- **Shorthand functions** - `tasks2`, `tasks3`, `tasks4` for quick column setup +- **Spacing & alignment controls** - Fine-grained gutters, label alignment, baselines, and block spacing +- **Wrap zone** - Reserve a top-right corner for overlay content (QR codes, stamps) and flow task rows around it + +## Manual + +A full manual is available as a [PDF](https://github.com/nathan-ed/typst-package-taskize/blob/cb28f846af4b83863be59166129b8a754e70d2fb/docs/manual.pdf), with a Typst source version in [docs/manual.typ](https://github.com/nathan-ed/typst-package-taskize/blob/cb28f846af4b83863be59166129b8a754e70d2fb/docs/manual.typ). + +## Quick Start + +```typst +#import "@preview/taskize:0.2.8": tasks + +#tasks[ + + First item + + Second item + + Third item + + Fourth item +] +``` + +## Basic Usage + +### Simple Two-Column Layout + +```typst +#import "@preview/taskize:0.2.8": tasks + +#tasks[ + + $2 + 3 = ?$ + + $5 - 2 = ?$ + + $4 times 3 = ?$ + + $8 div 2 = ?$ +] +``` + +### Three-Column Layout + +```typst +#import "@preview/taskize:0.2.8": tasks + +#tasks(columns: 3)[ + + Option A + + Option B + + Option C + + Option D + + Option E + + Option F +] +``` + +### Auto-Fit Columns + +Use `columns: "auto-fit"` to choose the largest column count that does not make any item wrap or overflow compared with a one-column layout. Limit the search with `max-columns`. + +```typst +#import "@preview/taskize:0.2.8": tasks + +#tasks(columns: "auto-fit", max-columns: 4)[ + + Medium length task with detail + + Short + + Short + + Short +] +``` + +By default, auto-fit uses `auto-fit-mode: "fill"`: an item too wide for one +column automatically spans the minimum number of columns it needs — no +explicit `+(N)` required — so wide items share rows with densely packed short +items. Use `auto-fit-mode: "uniform"` when every item must fit one ordinary +column; in that mode, the column count is the largest value where no item +wraps at one-column width, so a wide item can force the whole block to fewer +columns. If an item already wraps in the one-column layout, auto-fit keeps +one column, because no no-wrap multi-column layout exists. + +### Shorthand Functions + +```typst +#import "@preview/taskize:0.2.8": tasks2, tasks3, tasks4 + +// Two columns +#tasks2[ + + Item 1 + + Item 2 +] + +// Three columns +#tasks3[ + + Item 1 + + Item 2 + + Item 3 +] + +// Four columns +#tasks4[ + + A + + B + + C + + D +] +``` + +## Label Formats + +### Built-in Formats + +```typst +#import "@preview/taskize:0.2.8": tasks + +// Lowercase letters with parenthesis (default) +#tasks(label: "a)")[+ One + Two + Three] + +// Uppercase letters +#tasks(label: "A)")[+ One + Two + Three] + +// Numbers with parenthesis +#tasks(label: "1)")[+ One + Two + Three] + +// Numbers in parentheses +#tasks(label: "(1)")[+ One + Two + Three] + +// Lowercase roman numerals +#tasks(label: "i)")[+ One + Two + Three] + +// Uppercase roman numerals +#tasks(label: "I)")[+ One + Two + Three] + +// Bullet points +#tasks(label: "*")[+ One + Two + Three] + +// Dashes +#tasks(label: "-")[+ One + Two + Three] + +// No labels +#tasks(label: none)[+ One + Two + Three] +``` + +### Custom Label Function + +```typst +#import "@preview/taskize:0.2.8": tasks + +// Custom emoji labels +#tasks(label: n => "Q" + str(n) + ":")[ + + What is 2+2? + + What is the capital of France? + + What color is the sky? +] +``` + +## Flow Direction + +### Horizontal Flow (Default) + +Items fill rows first: `a b | c d | e f` + +```typst +#import "@preview/taskize:0.2.8": tasks + +#tasks(columns: 2, flow: "horizontal")[ + + a + + b + + c + + d + + e + + f +] +// Layout: +// a) b) +// c) d) +// e) f) +``` + +### Vertical Flow + +Items fill columns first: `a c e | b d f` + +```typst +#import "@preview/taskize:0.2.8": tasks + +#tasks(columns: 2, flow: "vertical")[ + + a + + b + + c + + d + + e + + f +] +// Layout: +// a) d) +// b) e) +// c) f) +``` + +## Resuming Numbering + +```typst +#import "@preview/taskize:0.2.8": tasks, tasks-reset + +#tasks[ + + First + + Second + + Third +] + +Some text between task blocks... + +// Continue numbering from where we left off +#tasks(resume: true)[ + + Fourth + + Fifth + + Sixth +] + +// Reset counter to start fresh +#tasks-reset() + +#tasks[ + + Back to one + + Two again +] +``` + +## Starting from a Specific Number + +```typst +#import "@preview/taskize:0.2.8": tasks + +#tasks(start: 5)[ + + This is item 5 + + This is item 6 + + This is item 7 +] +``` + +## Global Configuration + +Set defaults for all tasks in your document: + +```typst +#import "@preview/taskize:0.2.8": tasks, tasks-setup + +// Configure global defaults +#tasks-setup( + columns: 3, + label-format: "1)", + column-gutter: 1.5em, + row-gutter: 0.8em, +) + +// All subsequent tasks use these defaults +#tasks[ + + Item 1 + + Item 2 + + Item 3 +] + +// Override specific settings per-call +#tasks(columns: 2, label: "a)")[ + + Override A + + Override B +] +``` + +## Advanced Features + +### Label Baseline Alignment + +Control the vertical alignment of labels relative to content: + +```typst +#import "@preview/taskize:0.2.8": tasks + +// Center alignment (default) +#tasks(label-baseline: "center")[ + + Short item + + Multi-line item\ + with more text +] + +// Top alignment +#tasks(label-baseline: "top")[ + + Item aligned at top + + Another item +] + +// Bottom alignment +#tasks(label-baseline: "bottom")[ + + Item aligned at bottom + + Another item +] +``` + +### Bold Labels + +Make labels stand out by using bold weight: + +```typst +#import "@preview/taskize:0.2.8": tasks + +// Bold labels for emphasis +#tasks(label-weight: "bold")[ + + Important task + + Critical item + + Key point +] + +// Regular labels (default) +#tasks(label-weight: "regular")[ + + Normal item + + Standard task +] +``` + +### Column Spanning + +Items can span multiple columns using the `+()` or `+(N)` syntax (no space after `+`). This is similar to LaTeX's `\task*` command: + +```typst +#import "@preview/taskize:0.2.8": tasks + +#tasks(columns: 3)[ + + Short + + Short + + Short + +(2) This item spans 2 columns + + Short + +() This item spans all columns (full width) + + Normal + + Normal +] + +// Practical example: Multiple choice with explanation +#tasks(columns: 2, label: "A)")[ + + Paris + + London + + Berlin + + Madrid + +() The capital of France is Paris, known for the Eiffel Tower. +] +``` + +**Syntax:** +- `+ Content` - Normal item (spans 1 column) +- `+(2) Content` - Spans 2 columns (no space after +) +- `+(3) Content` - Spans 3 columns +- `+() Content` - Spans all columns (full width) + +**Notes:** +- **Important:** No space between `+` and `()` or `(N)` +- If an item with span doesn't fit in the current row, it wraps to the next row +- Column spans are clamped to the total number of columns +- Great for adding explanations, headings, or long content within lists + +## Parameters Reference + +### `tasks` Function + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `columns` | int/string | 2 | Number of columns, or `"auto-fit"` to choose the largest no-wrap count | +| `label` | string/function | `"a)"` | Label format (shorthand for `label-format`) | +| `label-format` | string/function | `"a)"` | Label format | +| `start` | int | 1 | Starting number for labels | +| `resume` | bool | false | Continue numbering from previous block | +| `column-gutter` | length | 1em | Space between columns | +| `row-gutter` | length/string | 0.6em | Space between rows. `"auto"` (or `"adaptive"`) sizes rows to the true ink of their content so tall inline math (display fractions, matrices) no longer bleeds into the gutter | +| `max-columns` | int/auto | auto | Maximum column count tested by `columns: "auto-fit"`; `auto` means up to the number of items | +| `auto-fit-mode` | string | `"fill"` | `"fill"` measures spans at their rendered width; `"uniform"` requires every item to fit one ordinary column | +| `auto-fit-tolerance` | length | 0.5pt | Measurement tolerance for auto-fit wrap detection | +| `label-width` | auto/length | auto | Width reserved for labels | +| `label-align` | alignment | right | Label alignment | +| `label-baseline` | string/length | `"center"` | Label vertical alignment: `"center"`, `"top"`, `"bottom"`, or length | +| `label-weight` | string | `"regular"` | Label font weight: `"regular"` or `"bold"` | +| `indent-after-label` | length | 0.4em | Space between label and content | +| `indent` | length | 0pt | Left indentation of entire block | +| `above` | length | 0.5em | Space before block | +| `below` | length | 0.5em | Space after block | +| `flow` | string | `"horizontal"` | Flow direction: `"horizontal"` or `"vertical"` | + +### Label Format Options + +| Format | Example | Description | +|--------|---------|-------------| +| `"a)"` | a) b) c) | Lowercase letters with closing paren | +| `"a."` | a. b. c. | Lowercase letters with period | +| `"(a)"` | (a) (b) (c) | Lowercase letters in parentheses | +| `"A)"` | A) B) C) | Uppercase letters with closing paren | +| `"A."` | A. B. C. | Uppercase letters with period | +| `"1)"` | 1) 2) 3) | Numbers with closing paren | +| `"1."` | 1. 2. 3. | Numbers with period | +| `"(1)"` | (1) (2) (3) | Numbers in parentheses | +| `"i)"` | i) ii) iii) | Lowercase roman numerals | +| `"I)"` | I) II) III) | Uppercase roman numerals | +| `"*"` | bullet | Bullet points | +| `"-"` | dash | En-dashes | +| `none` | (none) | No labels | +| function | custom | Custom function `n => content` | + +## Examples + +### Math Exercise Sheet + +```typst +#import "@preview/taskize:0.2.8": tasks + += Algebra Practice + +Simplify the following expressions: + +#tasks(columns: 2)[ + + $x^2 + 2x + 1$ + + $(a + b)^2$ + + $3x - 5 + 2x + 8$ + + $frac(x^2 - 4, x - 2)$ +] + +Solve for $x$: + +#tasks(columns: 2, label: "1)")[ + + $2x + 5 = 13$ + + $x^2 = 16$ + + $3(x - 2) = 15$ + + $frac(x, 4) = 7$ +] +``` + +### Multiple Choice Questions + +```typst +#import "@preview/taskize:0.2.8": tasks + +*Question 1:* What is the capital of France? + +#tasks(columns: 2, label: "A)")[ + + London + + Paris + + Berlin + + Madrid +] + +*Question 2:* Which planet is closest to the Sun? + +#tasks(columns: 2, label: "A)")[ + + Venus + + Mercury + + Mars + + Earth +] +``` + +### Vocabulary List + +```typst +#import "@preview/taskize:0.2.8": tasks + += French Vocabulary + +#tasks(columns: 3, label: none)[ + + *bonjour* - hello + + *merci* - thank you + + *oui* - yes + + *non* - no + + *s'il vous plait* - please + + *au revoir* - goodbye +] +``` + +## When to Use taskize + +**taskize** is specifically designed for **horizontal columned layouts** where items flow left-to-right across multiple columns, perfect for exercises, multiple-choice questions, and compact lists. Choose taskize when you need: + +- **Horizontal flow layout** - Items arranged in rows across columns (a b | c d | e f) +- **Compact exercise sheets** - Mathematical exercises, vocabulary lists, multiple-choice questions +- **Flexible column control** - Easy 2, 3, 4+ column layouts with shorthand functions +- **Resume numbering** - Continue numbering across multiple task blocks +- **LaTeX tasks compatibility** - Familiar syntax for users migrating from LaTeX's tasks package + +### Comparison with itemize + +**[itemize](https://typst.app/universe/package/itemize)** is an excellent, feature-rich package for **vertical list styling** with advanced customization capabilities. Use itemize when you need: + +- **Vertical lists** with advanced styling (colors, custom markers, spacing) +- **Nested lists** with sophisticated formatting +- **Rich marker customization** (emojis, custom content, per-item markers) +- **Advanced typography** control over list appearance +- **Complex list hierarchies** with fine-grained control + +**In summary**: Use taskize for horizontal multi-column layouts (exercises, quizzes, compact lists), and use itemize for feature-rich vertical lists with advanced styling. They complement each other well for different layout needs. + +## License + +MIT License - see LICENSE file for details. + +## Changelog + +All notable changes to taskize are documented here. + +### [0.2.8] - 2026-07-17 + +#### Added +- **Wrap zone** - Reserve a rectangular zone at the top right of a `#tasks` call with the `wrap-zone` parameter (or the shared `taskize-wrap-zone` state) so task rows flow around overlay content placed there independently — a QR code, a stamp, a logo. Rows overlapping the zone render narrowed beside it; later rows return to full width once the zone's height is cleared. The shared state is the cross-package contract wrapper packages (e.g. exercise-bank) use to reserve space for content they draw themselves. +- **Adaptive row heights** - `row-gutter: "auto"` (or `"adaptive"`) sizes rows to the true ink of their content, so tall inline math (display fractions, matrices) no longer bleeds into the row-gutter and the configured numeric gutter becomes the real visual gap. Rows with ordinary content keep exactly the same spacing as before. Can be set document-wide with `tasks-setup(row-gutter: "adaptive")`. + +### [0.2.7] - 2026-07-13 + +#### Added +- **Auto-fit columns** - `columns: "auto-fit"` selects the largest column count, bounded by `max-columns` (default: up to the number of items), that does not introduce a new item wrap or fixed-size content overflow. Useful for exercises mixing short choices, longer text, math, spans, and small figures. +- **Auto-spanning in fill mode** - with `auto-fit-mode: "fill"` (default), a regular `+` item too wide for one column automatically spans the minimum number of columns it needs — no explicit `+(N)` required — so wide items share rows with densely packed short items. `auto-fit-mode: "uniform"` instead requires every item to fit one ordinary column and falls back to fewer columns when the widest item no longer fits. + +### [0.2.6] - 2026-07-02 + +#### Added +- **True baseline alignment** — with `label-baseline: "center"` (default), inline content (text, inline math, symbols) now shares the same paragraph line as the label via hanging-indent layout, so fractions and tall inline elements align their baselines with the label instead of sitting above it + +#### Fixed +- `compiler` minimum lowered from `0.14.2` to `0.14.0` — patch releases add no new language features, so users on `0.14.0`/`0.14.1` were incorrectly excluded + +### [0.2.5] - 2026-01-27 + +#### Added +- Full manual (`docs/manual.typ` + `docs/manual.pdf`) with structured examples +- Expanded gallery with styled configuration and resume numbering examples + +#### Fixed +- **Indentation-independent parsing** - Tasks now render identically regardless of source code indentation + - All `+` items at any indentation level in the same `tasks[]` block are automatically flattened to the same level + - Inconsistent indentation no longer creates nested enums with different numbering + - Moving one character in the source no longer breaks the layout +- **Column spanning support** - Fixed `+(2)` and `+()` syntax to work correctly with the new parser + - Handles both `enum.item` nodes and text nodes starting with "+" +- Multiline tasks now capture unindented continuation lines as part of the previous item + +#### Changed +- Internal parser completely rewritten to recursively extract and flatten all enum items +- Parent items with nested enums (from indentation) have enum nodes stripped from their content +- README gallery layout refreshed and feature list expanded +- Documentation updated for the new manual and examples + +### [0.2.0] - 2026-01-15 + +#### Fixed +- **Indentation-independent parsing** - Tasks now render identically regardless of source code indentation + - All `+` items at any indentation level in the same `tasks[]` block are automatically flattened to the same level + - Inconsistent indentation no longer creates nested enums with different numbering + - Moving one character in the source no longer breaks the layout +- **Column spanning support** - Fixed `+(2)` and `+()` syntax to work correctly with the new parser + - Handles both `enum.item` nodes and text nodes starting with "+" + +#### Changed +- Internal parser completely rewritten to recursively extract and flatten all enum items +- Parent items with nested enums (from indentation) have enum nodes stripped from their content + +#### Added +- **Column spanning** - Items can span multiple columns using `(N)` syntax (e.g., `+ (2) Content` or `+ () Full width`) +- `label-baseline` parameter for controlling vertical alignment of labels (`"center"`, `"top"`, `"bottom"`, or custom length) +- `label-weight` parameter for bold labels (`"regular"` or `"bold"`) +- Advanced Features section in documentation with examples +- Gallery examples for column spanning and bold labels + +#### Changed +- Repository URL updated to GitLab: `https://gitlab.com/nathan-ed/typst-package-taskize` +- Description enhanced to mention "Horizontal and vertical" flow directions + +### [0.1.0] - 2026-01-14 + +#### Added +- Initial release with horizontal columned list layout +- Flexible column counts (2, 3, 4, or custom) +- Multiple label formats (alphabetic, numeric, roman, bullets, custom) +- Resume numbering across multiple task blocks +- Two flow directions: horizontal (a b | c d) and vertical (a c | b d) +- Global configuration system with `tasks-setup()` +- Shorthand functions `tasks2`, `tasks3`, `tasks4` +- Comprehensive parameter customization +- Full documentation with examples and parameter reference diff --git a/packages/preview/taskize/0.2.8/gallery/adaptive.svg b/packages/preview/taskize/0.2.8/gallery/adaptive.svg new file mode 100644 index 0000000000..fdc305081a --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/adaptive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/taskize/0.2.8/gallery/adaptive.typ b/packages/preview/taskize/0.2.8/gallery/adaptive.typ new file mode 100644 index 0000000000..7a0589e978 --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/adaptive.typ @@ -0,0 +1,22 @@ +// Adaptive row heights: row-gutter: "auto" keeps tall math out of the gutter. +#import "@preview/taskize:0.2.8": tasks + +#set page(width: 11cm, height: auto, margin: 6mm) +#set text(size: 11pt) +#show math.frac: it => math.display(it) + +*Fixed `row-gutter` — fraction ink bleeds into the gutter:* +#tasks(columns: 2)[ + + $1/2 + 1/3$ + + $3/4 - 1/8$ + + $5/6 dot 2/5$ + + $7/8 : 1/2$ +] + +*`row-gutter: "auto"` — rows sized to true ink, same gutter honored:* +#tasks(columns: 2, row-gutter: "auto")[ + + $1/2 + 1/3$ + + $3/4 - 1/8$ + + $5/6 dot 2/5$ + + $7/8 : 1/2$ +] diff --git a/packages/preview/taskize/0.2.8/gallery/basic.svg b/packages/preview/taskize/0.2.8/gallery/basic.svg new file mode 100644 index 0000000000..59ada8cb34 --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/basic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/taskize/0.2.8/gallery/basic.typ b/packages/preview/taskize/0.2.8/gallery/basic.typ new file mode 100644 index 0000000000..bc757e09eb --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/basic.typ @@ -0,0 +1,12 @@ +#import "@preview/taskize:0.2.8": tasks + +#set page(width: 12cm, height: auto, margin: 1cm) + += Basic Tasks + +#tasks[ + + First item + + Second item + + Third item + + Fourth item +] diff --git a/packages/preview/taskize/0.2.8/gallery/bold.svg b/packages/preview/taskize/0.2.8/gallery/bold.svg new file mode 100644 index 0000000000..db6960829b --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/taskize/0.2.8/gallery/bold.typ b/packages/preview/taskize/0.2.8/gallery/bold.typ new file mode 100644 index 0000000000..fa23ddf0c0 --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/bold.typ @@ -0,0 +1,40 @@ +#import "@preview/taskize:0.2.8": tasks + +#set page(width: 12cm, height: auto, margin: 1cm) + += Bold Labels + +Regular weight labels (default): + +#tasks[ + + First item + + Second item + + Third item + + Fourth item +] + +Bold weight labels for emphasis: + +#tasks(label-weight: "bold")[ + + Important task + + Critical item + + Key point + + Must-do action +] + +Bold labels with different formats: + +#tasks(label: "1)", label-weight: "bold")[ + + Complete assignment + + Review notes + + Submit project +] + +#tasks(columns: 3, label: "A)", label-weight: "bold")[ + + Option A + + Option B + + Option C + + Option D + + Option E + + Option F +] diff --git a/packages/preview/taskize/0.2.8/gallery/labels.svg b/packages/preview/taskize/0.2.8/gallery/labels.svg new file mode 100644 index 0000000000..e754a20d22 --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/labels.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/taskize/0.2.8/gallery/labels.typ b/packages/preview/taskize/0.2.8/gallery/labels.typ new file mode 100644 index 0000000000..ebdb27b1c2 --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/labels.typ @@ -0,0 +1,23 @@ +#import "@preview/taskize:0.2.8": tasks + +#set page(width: 12cm, height: auto, margin: 1cm) + += Label Formats + +Lowercase letters: +#tasks(columns: 3, label: "a)")[+ One + Two + Three] + +Uppercase letters: +#tasks(columns: 3, label: "A)")[+ One + Two + Three] + +Numbers: +#tasks(columns: 3, label: "1)")[+ One + Two + Three] + +Roman numerals: +#tasks(columns: 3, label: "i)")[+ One + Two + Three] + +Bullets: +#tasks(columns: 3, label: "*")[+ One + Two + Three] + +Custom: +#tasks(columns: 3, label: n => "Q" + str(n) + ":")[+ One + Two + Three] diff --git a/packages/preview/taskize/0.2.8/gallery/math.svg b/packages/preview/taskize/0.2.8/gallery/math.svg new file mode 100644 index 0000000000..9750b6a03e --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/math.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/taskize/0.2.8/gallery/math.typ b/packages/preview/taskize/0.2.8/gallery/math.typ new file mode 100644 index 0000000000..0ce94b89ec --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/math.typ @@ -0,0 +1,23 @@ +#import "@preview/taskize:0.2.8": tasks + +#set page(width: 12cm, height: auto, margin: 1cm) + += Math Exercises + +Simplify: + +#tasks(columns: 2)[ + + $x^2 + 2x + 1$ + + $(a + b)^2$ + + $3x - 5 + 2x$ + + $frac(x^2 - 4, x - 2)$ +] + +Solve for $x$: + +#tasks(columns: 2, label: "1)")[ + + $2x + 5 = 13$ + + $x^2 = 16$ + + $3(x - 2) = 15$ + + $frac(x, 4) = 7$ +] diff --git a/packages/preview/taskize/0.2.8/gallery/resume.svg b/packages/preview/taskize/0.2.8/gallery/resume.svg new file mode 100644 index 0000000000..175ae6e562 --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/resume.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/taskize/0.2.8/gallery/resume.typ b/packages/preview/taskize/0.2.8/gallery/resume.typ new file mode 100644 index 0000000000..15f44dd726 --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/resume.typ @@ -0,0 +1,29 @@ +#import "@preview/taskize:0.2.8": tasks, tasks-reset + +#set page(width: 12cm, height: auto, margin: 1cm) + += Resume Numbering + +First block: +#tasks[ + + First + + Second + + Third +] + +Some text between blocks... + +Second block (resumed): +#tasks(resume: true)[ + + Fourth + + Fifth + + Sixth +] + +After reset: +#tasks-reset() + +#tasks[ + + Back to one + + Two again +] diff --git a/packages/preview/taskize/0.2.8/gallery/span.svg b/packages/preview/taskize/0.2.8/gallery/span.svg new file mode 100644 index 0000000000..3056c2b137 --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/span.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/taskize/0.2.8/gallery/span.typ b/packages/preview/taskize/0.2.8/gallery/span.typ new file mode 100644 index 0000000000..a71514bffc --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/span.typ @@ -0,0 +1,40 @@ +#import "@preview/taskize:0.2.8": tasks + +#set page(width: 14cm, height: auto, margin: 1cm) + += Column Spanning + +Basic spanning in 3 columns: + +#tasks(columns: 3)[ + + Item A + + Item B + + Item C + +(2) This spans 2 columns + + Item F + +() This item spans all 3 columns + + Item G + + Item H +] + +Multiple choice with explanation: + +#tasks(columns: 2, label: "A)")[ + + Paris + + London + + Berlin + + Rome + +() *Explanation:* Paris is the capital and largest city of France, known for the Eiffel Tower and as a global center of art and culture. +] + +Exercise with full-width notes: + +#tasks(columns: 3, label: "1)")[ + + $x + 2 = 5$ + + $3x = 12$ + + $2x - 1 = 7$ + +() *Hint:* Isolate the variable on one side of the equation + + $x^2 = 16$ + + $5x + 3 = 18$ + + $4x - 2 = 10$ +] diff --git a/packages/preview/taskize/0.2.8/gallery/styled.svg b/packages/preview/taskize/0.2.8/gallery/styled.svg new file mode 100644 index 0000000000..e633571f8c --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/styled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/taskize/0.2.8/gallery/styled.typ b/packages/preview/taskize/0.2.8/gallery/styled.typ new file mode 100644 index 0000000000..043a327e06 --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/styled.typ @@ -0,0 +1,47 @@ +#import "@preview/taskize:0.2.8": tasks, tasks-setup + +#set page(width: 12cm, height: auto, margin: 1cm) + += Styled Tasks + +#tasks-setup( + columns: 3, + label-format: "1)", + column-gutter: 1.5em, + row-gutter: 0.8em, + label-weight: "bold", + label-baseline: "center", +) + +== Global Configuration + +#tasks[ + + Item 1 + + Item 2 + + Item 3 + + Item 4 + + Item 5 + + Item 6 +] + +== Four Columns + +#tasks(columns: 4, label-weight: "regular")[ + + A + + B + + C + + D + + E + + F + + G + + H +] + +== With Indentation + +#tasks(indent: 2em, columns: 2, label-weight: "bold")[ + + Indented item + + Another one + + Third item + + Fourth item +] diff --git a/packages/preview/taskize/0.2.8/gallery/vertical.svg b/packages/preview/taskize/0.2.8/gallery/vertical.svg new file mode 100644 index 0000000000..966cb1c8eb --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/vertical.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/taskize/0.2.8/gallery/vertical.typ b/packages/preview/taskize/0.2.8/gallery/vertical.typ new file mode 100644 index 0000000000..b28046a63d --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/vertical.typ @@ -0,0 +1,29 @@ +#import "@preview/taskize:0.2.8": tasks + +#set page(width: 12cm, height: auto, margin: 1cm) + += Flow Directions + +== Horizontal Flow (default) +Items fill rows first: a b | c d | e f + +#tasks(columns: 2, flow: "horizontal")[ + + a + + b + + c + + d + + e + + f +] + +== Vertical Flow +Items fill columns first: a c e | b d f + +#tasks(columns: 2, flow: "vertical")[ + + a + + b + + c + + d + + e + + f +] diff --git a/packages/preview/taskize/0.2.8/gallery/wrap-zone.svg b/packages/preview/taskize/0.2.8/gallery/wrap-zone.svg new file mode 100644 index 0000000000..590d5c0d23 --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/wrap-zone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/taskize/0.2.8/gallery/wrap-zone.typ b/packages/preview/taskize/0.2.8/gallery/wrap-zone.typ new file mode 100644 index 0000000000..49837e4084 --- /dev/null +++ b/packages/preview/taskize/0.2.8/gallery/wrap-zone.typ @@ -0,0 +1,27 @@ +// Wrap zone: reserve a top-right corner for overlay content (e.g. a QR code) +// and flow task rows around it. +#import "@preview/taskize:0.2.8": tasks, taskize-wrap-zone + +#set page(width: 12cm, height: auto, margin: 1cm) + += Exercise Sheet + +#taskize-wrap-zone.update((width: 2.4cm, height: 2.4cm)) +#place(top + right, box( + width: 2.4cm, + height: 2.4cm, + stroke: 0.5pt + luma(60%), + radius: 2pt, + align(center + horizon)[#text(size: 8pt, fill: luma(60%))[QR code]], +)) + +#tasks(columns: 2)[ + + $2 + 3 = ?$ + + $5 - 1 = ?$ + + $4 times 2 = ?$ + + $9 div 3 = ?$ + + $7 + 6 = ?$ + + $12 - 4 = ?$ + + $3 times 5 = ?$ + + $10 div 2 = ?$ +] diff --git a/packages/preview/taskize/0.2.8/lib.typ b/packages/preview/taskize/0.2.8/lib.typ new file mode 100644 index 0000000000..45bf3a605a --- /dev/null +++ b/packages/preview/taskize/0.2.8/lib.typ @@ -0,0 +1,1033 @@ +// taskize - Horizontal columned lists for Typst +// Similar to LaTeX's tasks package +// Items flow horizontally (left-to-right) across columns + +// ============================================================================= +// Configuration State +// ============================================================================= + +#let tasks-config = state("tasks-config", ( + columns: 2, + label-format: "a)", // "a)", "1)", "i)", "(a)", "(1)", custom function + column-gutter: 1em, + row-gutter: 0.6em, + max-columns: auto, // auto = up to number of items for auto-fit + auto-fit-mode: "fill", // "fill" = spans may use their span; "uniform" = every item must fit one column + auto-fit-tolerance: 0.5pt, // measurement tolerance for wrap detection + label-width: auto, // auto or fixed width + label-align: right, + label-baseline: "center", // "center", "top", "bottom", or length/auto + label-weight: "regular", // "regular" or "bold" + indent-after-label: 0.4em, + indent: 0pt, // left indentation of entire block + above: 0.5em, // space before block + below: 0.5em, // space after block + flow: "horizontal", // "horizontal" (a b | c d) or "vertical" (a c | b d) +)) + +// Global counter for resuming +#let tasks-counter = counter("tasks-counter") + +// Rectangular zone (dict (width:, height:), absolute lengths) reserved at the +// top right of the next #tasks call: its first rows render beside the zone at +// reduced width, the remaining rows below at full width. Meant for wrapper +// packages that overlay content there (e.g. exercise-bank's QR codes) — they +// set this state before the tasks body and clear it after. The state key is +// the cross-package contract: any package may set state("taskize-wrap-zone"). +#let taskize-wrap-zone = state("taskize-wrap-zone", none) + +// ============================================================================= +// Configuration Function +// ============================================================================= + +#let tasks-setup( + columns: none, + label-format: none, + column-gutter: none, + row-gutter: none, + max-columns: none, + auto-fit-mode: none, + auto-fit-tolerance: none, + label-width: none, + label-align: none, + label-baseline: none, + label-weight: none, + indent-after-label: none, + indent: none, + above: none, + below: none, + flow: none, +) = { + tasks-config.update(cfg => { + let new = cfg + if columns != none { new.columns = columns } + if label-format != none { new.label-format = label-format } + if column-gutter != none { new.column-gutter = column-gutter } + if row-gutter != none { new.row-gutter = row-gutter } + if max-columns != none { new.max-columns = max-columns } + if auto-fit-mode != none { new.auto-fit-mode = auto-fit-mode } + if auto-fit-tolerance != none { new.auto-fit-tolerance = auto-fit-tolerance } + if label-width != none { new.label-width = label-width } + if label-align != none { new.label-align = label-align } + if label-baseline != none { new.label-baseline = label-baseline } + if label-weight != none { new.label-weight = label-weight } + if indent-after-label != none { new.indent-after-label = indent-after-label } + if indent != none { new.indent = indent } + if above != none { new.above = above } + if below != none { new.below = below } + if flow != none { new.flow = flow } + new + }) +} + +// ============================================================================= +// Column Span Parsing +// ============================================================================= + +// Parse column span syntax from item content +// Returns (span, content) tuple +// Syntax: () for all columns, (N) for N columns, otherwise 1 column +#let parse-span(item-content, max-cols) = { + // Try to extract text representation from content + let text-str = none + + // Handle different content types + if type(item-content) == str { + text-str = item-content + } else if type(item-content) == content { + // Try to get the text representation + if item-content.has("text") { + text-str = item-content.text + } else if item-content.has("body") and type(item-content.body) == str { + text-str = item-content.body + } else if item-content.has("children") { + // Extract text from first child if it's text + let children = item-content.children + if children.len() > 0 { + let first = children.at(0) + if type(first) == str { + text-str = first + } else if type(first) == content and first.has("text") { + text-str = first.text + } + } + } + } + + // If we got text, check for span patterns + if text-str != none and type(text-str) == str { + // Remove leading + if present (from +(N) syntax) + let clean-text = if text-str.starts-with("+") { + text-str.slice(1) + } else { + text-str + } + + // Check for () pattern - span all columns + if clean-text.starts-with("()") { + let remaining = clean-text.slice(2) + // Remove leading space if present + if remaining.len() > 0 and remaining.starts-with(" ") { + remaining = remaining.slice(1) + } + return (max-cols, remaining) + } + + // Check for (N) pattern - span N columns + if clean-text.starts-with("(") { + let close-paren = clean-text.position(")") + if close-paren != none and close-paren > 1 { + let num-str = clean-text.slice(1, close-paren) + // Check if num-str contains only digits + if num-str.len() > 0 and num-str.match(regex("^\d+$")) != none { + let parsed = int(num-str) + let remaining = clean-text.slice(close-paren + 1) + // Remove leading space if present + if remaining.len() > 0 and remaining.starts-with(" ") { + remaining = remaining.slice(1) + } + return (calc.min(calc.max(parsed, 1), max-cols), remaining) + } + } + } + } + + // Default: no span specified + return (1, item-content) +} + +// ============================================================================= +// Label Formatting +// ============================================================================= + +// Convert number to label based on format +#let format-label(n, format) = { + if type(format) == function { + format(n) + } else if format == "a)" { + numbering("a)", n) + } else if format == "a." { + numbering("a.", n) + } else if format == "(a)" { + numbering("(a)", n) + } else if format == "1)" { + numbering("1)", n) + } else if format == "1." { + numbering("1.", n) + } else if format == "(1)" { + numbering("(1)", n) + } else if format == "i)" { + numbering("i)", n) + } else if format == "i." { + numbering("i.", n) + } else if format == "(i)" { + numbering("(i)", n) + } else if format == "I)" { + numbering("I)", n) + } else if format == "A)" { + numbering("A)", n) + } else if format == "A." { + numbering("A.", n) + } else if format == "*" { + // Bullet points + sym.bullet + } else if format == "-" { + // Dash + "–" + } else if format == "none" or format == none { + // No label + "" + } else { + // Try as numbering pattern + numbering(format, n) + } +} + +// ============================================================================= +// Adaptive row heights ("auto" row-gutter) +// ============================================================================= + +// Rows containing tall inline math (display fractions, matrices, ...) report +// a height based on the font's cap-height/baseline edges, not the actual ink, +// so the ink bleeds into the row-gutter. This rule measures each inline +// equation with bounds text edges and switches only the tall ones over, so +// row heights become truthful while ordinary rows keep their exact leading. +// (Same approach as the breather package, inlined to avoid a dependency.) +#let _breathe-rule(body) = { + show math.equation.where(block: false): it => context { + let bounded = { + set text(top-edge: "bounds", bottom-edge: "bounds") + it + } + if measure(bounded).height > 1.1em.to-absolute() { bounded } else { it } + } + body +} + +#let _is-adaptive-row-gutter(value) = value == "auto" or value == "adaptive" + +// ============================================================================= +// Content Classification +// ============================================================================= + +// Returns true if the content can be laid out inline (in a paragraph line). +// Used to decide whether the label can share the first line with the content, +// which gives true baseline alignment (fractions, vectors, etc. no longer +// push the content below the label's baseline). +// Conservative: anything unknown is treated as block-level, which falls back +// to the previous top-aligned rendering. +#let is-inline-content(node) = { + if type(node) != content { + return type(node) in (str, int, float, symbol) + } + let f = node.func() + if f == math.equation { + return not node.block + } + let fname = repr(f) + if fname in ( + "text", "space", "linebreak", "h", + "strong", "emph", "sub", "super", "underline", "overline", "strike", + "highlight", "smartquote", "link", "ref", + ) { + return true + } + if fname == "sequence" { + return node.children.all(is-inline-content) + } + if fname == "styled" { + return is-inline-content(node.child) + } + return false +} + +// ============================================================================= +// Internal: Render tasks grid +// ============================================================================= + +#let _is-auto-fit-columns(cols) = { + cols == "auto-fit" or cols == "fit" +} + +#let _resolve-length(value) = { + if type(value) in (length, relative) { + measure(box(width: value)).width + } else { + value + } +} + +#let _compute-label-width(task-items, fmt, lbl-width, lbl-weight, indent-after, start-num) = { + if lbl-width == auto { + let max-width = 0pt + for i in range(task-items.len()) { + let label = format-label(start-num + i, fmt) + let label-size = measure(text(weight: lbl-weight)[#label]).width + if label-size > max-width { max-width = label-size } + } + max-width + indent-after + } else { + lbl-width + } +} + +#let _task-content-width(total-width, cols, span, lbl-width, col-gut, indent-after, indent) = { + let cols = calc.max(1, cols) + let span = calc.min(calc.max(1, span), cols) + let label-width = _resolve-length(lbl-width) + let column-gutter = _resolve-length(col-gut) + let label-gutter = _resolve-length(indent-after) + let block-indent = _resolve-length(indent) + let inner-width = total-width - block-indent + let fixed-width = cols * label-width + cols * label-gutter + (cols - 1) * column-gutter + let content-track = (inner-width - fixed-width) / cols + if content-track < 0pt { content-track = 0pt } + content-track * span + (span - 1) * (label-width + label-gutter + column-gutter) +} + +#let _task-height-at-width(content, width) = { + measure(block(width: width, content)).height +} + +#let _select-auto-fit-columns( + task-items, + max-cols, + fmt, + col-gut, + lbl-width, + lbl-weight, + indent-after, + indent, + start-num, + mode, + tolerance, + available-width, +) = { + if task-items.len() == 0 { return 1 } + + let max-cols = if max-cols == auto { task-items.len() } else { max-cols } + max-cols = calc.max(1, calc.min(max-cols, task-items.len())) + let label-width = _compute-label-width(task-items, fmt, lbl-width, lbl-weight, indent-after, start-num) + let tol = _resolve-length(tolerance) + + let base-heights = () + for item-data in task-items { + let content = item-data.at(0) + let width = _task-content-width(available-width, 1, 1, label-width, col-gut, indent-after, indent) + base-heights.push(_task-height-at-width(content, width)) + } + + let fits-cols(cols) = { + for (i, item-data) in task-items.enumerate() { + let content = item-data.at(0) + let explicit-span = item-data.at(1) + if mode == "uniform" or explicit-span > 1 { + // uniform: every item pinned to one column + // explicit span items: use their declared span, capped at cols + let span = if mode == "uniform" { 1 } else { calc.min(explicit-span, cols) } + let width = _task-content-width(available-width, cols, span, label-width, col-gut, indent-after, indent) + if measure(content).width > width + tol or _task-height-at-width(content, width) > base-heights.at(i) + tol { + return false + } + } else { + // fill mode, regular item: auto-span until height matches base. + // Only height is checked — text always reflows to fit any width, so + // natural-width would reject long sentences even at full-row span. + let fitted = false + for s in range(1, cols + 1) { + let width = _task-content-width(available-width, cols, s, label-width, col-gut, indent-after, indent) + if _task-height-at-width(content, width) <= base-heights.at(i) + tol { + fitted = true + break + } + } + if not fitted { return false } + } + } + true + } + + let best = 1 + for cols in range(1, max-cols + 1) { + if fits-cols(cols) { + best = cols + } else { + break + } + } + best +} + +#let render-tasks-grid( + task-items, + cols, + fmt, + col-gut, + row-gut, + lbl-width, + lbl-align, + lbl-baseline, + lbl-weight, + indent-after, + indent, + above-spacing, + below-spacing, + flow-dir, + start-num, + adaptive-rows: false, +) = { + let num-items = task-items.len() + let wrap-content = if adaptive-rows { _breathe-rule } else { c => c } + + // Calculate label width if auto + let actual-label-width = _compute-label-width(task-items, fmt, lbl-width, lbl-weight, indent-after, start-num) + + // Build grid columns (label + content pairs) + let grid-columns = () + for _ in range(cols) { + grid-columns.push(actual-label-width) + grid-columns.push(1fr) + } + + // Helper to create label cell + let make-label-cell(label) = { + let label-height = measure(text(weight: lbl-weight)[#label]).height + let text-height = measure[A].height + let baseline-offset = if lbl-baseline == "center" { + (label-height - text-height) * 0.5 + } else if lbl-baseline == "top" { + 0pt + } else if lbl-baseline == "bottom" { + label-height - text-height + } else if type(lbl-baseline) in (length, relative) { + lbl-baseline + } else { + 0pt + } + align(lbl-align + top)[#v(baseline-offset)#text(weight: lbl-weight)[#label]] + } + + // Helper to create content cell + let make-content-cell(content) = { + align(left + top)[ + #show math.vec: it => box(baseline: 30%, it) + #wrap-content(content) + ] + } + + // Baseline-aligned rendering: label and content share the same paragraph + // line, so their baselines match exactly even when the content starts with + // a tall element (display fraction, vector, ...). Wrapped lines are pushed + // past the label column with a hanging indent. + // Only used for inline content with the default label-baseline ("center"); + // block content and explicit baseline offsets keep the grid-cell rendering. + let use-inline-baseline = lbl-baseline == "center" + + let make-inline-cell(label, content, span) = { + let lbl = text(weight: lbl-weight)[#label] + let halign = if type(lbl-align) == alignment and lbl-align.x != none { + lbl-align.x + } else { + right + } + let label-box = box(width: actual-label-width, { + if halign == left { lbl; h(1fr) } + else if halign == center { h(1fr); lbl; h(1fr) } + else { h(1fr); lbl } + }) + // Explicit par(): bare content in a grid cell is not a paragraph element, + // so a `set par(hanging-indent: ..)` rule would not apply to it. + grid.cell(colspan: span * 2, align(left + top, par( + hanging-indent: actual-label-width + indent-after, + [#label-box#h(indent-after)#wrap-content(content)], + ))) + } + + // Build the cell(s) for one item: a single baseline-aligned cell when + // possible, otherwise the classic label cell + top-aligned content cell. + let make-item-cells(label, content, span) = { + if use-inline-baseline and is-inline-content(content) { + (make-inline-cell(label, content, span),) + } else if span == 1 { + (make-label-cell(label), make-content-cell(content)) + } else { + ( + make-label-cell(label), + grid.cell(colspan: span * 2 - 1, make-content-cell(content)), + ) + } + } + + // Build grid content with column spans + let grid-content = () + + if flow-dir == "vertical" { + // VERTICAL FLOW: Fill columns first (like v0.1.0), with column spanning support + // For vertical flow without column spans, we use the classic formula: + // For item i: col = i / num_rows, row = i % num_rows + // With column spans, items are placed sequentially in available positions + + // First pass: check if any items have spans + let has-spans = false + for item-data in task-items { + if calc.min(item-data.at(1), cols) > 1 { + has-spans = true + break + } + } + + if not has-spans { + // Simple case: no column spans, use the classic v0.1.0 algorithm + let num-rows = calc.ceil(num-items / cols) + + for row in range(num-rows) { + for col in range(cols) { + // Calculate item index using vertical flow formula + let item-idx = col * num-rows + row + + if item-idx < num-items { + let item-data = task-items.at(item-idx) + let item-content = item-data.at(0) + let num = start-num + item-idx + let label = format-label(num, fmt) + + grid-content += make-item-cells(label, item-content, 1) + } else { + // No more items, fill with empty cells + grid-content.push([]) + grid-content.push([]) + } + } + } + } else { + // Complex case: has column spans + // Build a 2D grid to track occupied positions + // Estimate initial number of rows (may grow) + let num-rows = calc.ceil(num-items / cols) + let grid-map = () // Array of arrays: grid-map.at(row).at(col) = item-idx or none or "occupied" + + // Initialize grid map + for r in range(num-rows + 10) { // Add extra rows in case spans push items down + let row-arr = () + for c in range(cols) { + row-arr.push(none) + } + grid-map.push(row-arr) + } + + // Place items in column-major order, respecting spans + // Track the current height (row) of each column + let col-heights = () + for _ in range(cols) { + col-heights.push(0) + } + + for item-idx in range(num-items) { + let item-data = task-items.at(item-idx) + let span = calc.min(item-data.at(1), cols) + + // Find the column with minimum height that can accommodate this span + let placed = false + let best-col = 0 + let best-row = 0 + + // Try each starting column position + for start-col in range(cols) { + if start-col + span > cols { + // Span doesn't fit starting from this column + continue + } + + // Check the maximum height among spanned columns + let max-height = 0 + for s in range(span) { + if col-heights.at(start-col + s) > max-height { + max-height = col-heights.at(start-col + s) + } + } + + // This is a valid position + if not placed or max-height < best-row { + best-col = start-col + best-row = max-height + placed = true + } + } + + if placed { + // Place item at (best-row, best-col) + grid-map.at(best-row).at(best-col) = item-idx + // Mark spanned positions as occupied + for s in range(1, span) { + grid-map.at(best-row).at(best-col + s) = "occupied" + } + // Update column heights for all spanned columns + for s in range(span) { + col-heights.at(best-col + s) = best-row + 1 + } + } + } + + // Find actual number of rows used + let actual-rows = 0 + for r in range(grid-map.len()) { + let row-has-content = false + for c in range(cols) { + if grid-map.at(r).at(c) != none { + row-has-content = true + break + } + } + if row-has-content { + actual-rows = r + 1 + } + } + + // Convert grid-map to grid-content + for row in range(actual-rows) { + let col = 0 + while col < cols { + let cell-value = grid-map.at(row).at(col) + if cell-value == none { + // Empty cell + grid-content.push([]) + grid-content.push([]) + col += 1 + } else if cell-value == "occupied" { + // Skip - this is part of a span from a previous column + // Don't add any cells, the colspan handles it + col += 1 + } else { + // Item cell + let item-idx = cell-value + let item-data = task-items.at(item-idx) + let item-content = item-data.at(0) + let span = calc.min(item-data.at(1), cols) + let num = start-num + item-idx + let label = format-label(num, fmt) + + grid-content += make-item-cells(label, item-content, span) + col += span // Skip the spanned columns + } + } + } + } + } else { + // HORIZONTAL FLOW: Original logic (fill rows first) + let current-col = 0 + let current-row = 0 + + for i in range(num-items) { + let item-data = task-items.at(i) + let item-content = item-data.at(0) + let span = calc.min(item-data.at(1), cols) + + // If current position would exceed columns, move to next row + if current-col + span > cols { + // Fill remaining columns in current row with empty cells + while current-col < cols { + grid-content.push([]) + grid-content.push([]) + current-col += 1 + } + current-col = 0 + current-row += 1 + } + + let num = start-num + i + let label = format-label(num, fmt) + + grid-content += make-item-cells(label, item-content, span) + + current-col += span + } + + // Fill remaining cells in last row + while current-col < cols { + grid-content.push([]) + grid-content.push([]) + current-col += 1 + } + } + + // Update counter + tasks-counter.update(start-num + num-items - 1) + + // Render + block( + width: 100%, + above: above-spacing, + below: below-spacing, + inset: (left: indent), + grid( + columns: grid-columns, + column-gutter: (indent-after, col-gut) * cols, + row-gutter: row-gut, + ..grid-content + ) + ) +} + +// ============================================================================= +// Main Tasks Function - Clean syntax with enum +// ============================================================================= + +// Usage: +// #tasks[ +// + $2 + 3$ +// + $5 - 2$ +// + $4 times 3$ +// ] +// +// Or with options: +// #tasks(columns: 3, label: "1)")[ +// + First item +// + Second item +// ] +// +// Flow options: +// flow: "horizontal" -> a b | c d | e f (default) +// flow: "vertical" -> a c e | b d f + +#let tasks( + columns: auto, + label: auto, // Shorthand for label-format + label-format: auto, + start: 1, + resume: false, + column-gutter: auto, + row-gutter: auto, + max-columns: auto, + auto-fit-mode: auto, + auto-fit-tolerance: auto, + label-width: auto, + label-align: auto, + label-baseline: auto, + label-weight: auto, + indent-after-label: auto, + indent: auto, + above: auto, + below: auto, + flow: auto, + wrap-zone: auto, // auto = honor taskize-wrap-zone state; none = ignore it; + // (width:, height:) = explicit top-right zone to flow around + body, +) = context { + let cfg = tasks-config.get() + + // Apply parameters (use provided value or fall back to config) + let requested-cols = if columns == auto { cfg.columns } else { columns } + let fmt = if label != auto { label } else if label-format != auto { label-format } else { cfg.label-format } + let col-gut = if column-gutter == auto { cfg.column-gutter } else { column-gutter } + let row-gut = if row-gutter == auto { cfg.row-gutter } else { row-gutter } + // "auto"/"adaptive" row-gutter: rows are sized to their true ink (tall + // inline math no longer bleeds into the gutter) and the numeric gutter + // falls back to the configured default. + let adaptive-rows = _is-adaptive-row-gutter(row-gut) + if adaptive-rows { + row-gut = if _is-adaptive-row-gutter(cfg.row-gutter) { 0.6em } else { cfg.row-gutter } + } + let max-cols = if max-columns == auto { cfg.max-columns } else { max-columns } + let fit-mode = if auto-fit-mode == auto { cfg.auto-fit-mode } else { auto-fit-mode } + let fit-tolerance = if auto-fit-tolerance == auto { cfg.auto-fit-tolerance } else { auto-fit-tolerance } + let lbl-width = if label-width == auto { cfg.label-width } else { label-width } + let lbl-align = if label-align == auto { cfg.label-align } else { label-align } + let lbl-baseline = if label-baseline == auto { cfg.label-baseline } else { label-baseline } + let lbl-weight = if label-weight == auto { cfg.label-weight } else { label-weight } + let indent-after = if indent-after-label == auto { cfg.indent-after-label } else { indent-after-label } + let blk-indent = if indent == auto { cfg.indent } else { indent } + let above-spacing = if above == auto { cfg.above } else { above } + let below-spacing = if below == auto { cfg.below } else { below } + let flow-dir = if flow == auto { cfg.flow } else { flow } + let span-parse-cols = if _is-auto-fit-columns(requested-cols) { + if max-cols == auto { 512 } else { max-cols } + } else { + requested-cols + } + + // Extract items from the body content - INDENTATION-INDEPENDENT PARSING + // Strategy: Flatten ALL enum.item nodes at any depth into a single list + // Nested enums from indentation differences are just more items + // Each item is stored as (content, span) tuple + let task-items = () + + // Merge extra content into the previous item (used for multiline tasks) + let merge-item-content(base, extra) = { + if extra == none { return base } + let base-content = if type(base) == content { base } else { [#base] } + let extra-content = if type(extra) == content { extra } else { [#extra] } + base-content + extra-content + } + + let append-to-last-item(items, extra) = { + if extra == none or items.len() == 0 { return items } + let last = items.at(items.len() - 1) + let merged = merge-item-content(last.at(0), extra) + let new-items = items.slice(0, items.len() - 1) + new-items.push((merged, last.at(1))) + new-items + } + + // Helper to remove ALL enum and enum.item nodes from content (they'll be extracted separately) + let strip-enums(node) = { + if type(node) != content { + return node + } + + // If this IS an enum or enum.item, return empty + if node.func() == enum or node.func() == enum.item { + return none + } + + // If no children, return as-is + if not node.has("children") { + return node + } + + // Process children recursively + let new-children = () + for child in node.children { + if type(child) == content { + if child.func() == enum or child.func() == enum.item { + // Skip enum and enum.item children entirely + continue + } + // Recursively clean + let cleaned = strip-enums(child) + if cleaned != none { + new-children.push(cleaned) + } + } else { + // Non-content (text, etc) - keep it + new-children.push(child) + } + } + + // Return result + if new-children.len() == 0 { + return none + } else if new-children.len() == 1 { + return new-children.at(0) + } else { + // Join multiple children + return new-children.join() + } + } + + // Recursively extract ALL enum.item nodes, flattening any nesting + let flatten-all-enum-items(node) = { + let items = () + + if type(node) != content { + return items + } + + if node.func() == enum { + // Enum - extract all items from it + for child in node.children { + items = items + flatten-all-enum-items(child) + } + } else if node.func() == enum.item { + // Found an item! + // First check if body contains nested enums (from inconsistent indentation) + let nested = flatten-all-enum-items(node.body) + + if nested.len() > 0 { + // Has nested items from inconsistent indentation + // Strip enum.item nodes from body before parsing + let cleaned-body = strip-enums(node.body) + + // Parse span from cleaned body + let (span, content-parsed) = if cleaned-body != none { + parse-span(cleaned-body, span-parse-cols) + } else { + (1, []) + } + + // Add parent item if it has content after cleaning + if content-parsed != [] { + items.push((content-parsed, span)) + } + + // Add all nested items at same level + items = items + nested + } else { + // No nesting - parse and add as-is + let (span, content-parsed) = parse-span(node.body, span-parse-cols) + items.push((content-parsed, span)) + } + } else if node.func() == text and node.text.starts-with("+") { + // Handle +(N) and +() syntax which gets parsed as text (not enum.item) + // This happens when there's no space after + + let text-content = node.text.slice(1) // Remove the + + let (span, content) = parse-span(text-content, span-parse-cols) + items.push((content, span)) + } else if node.has("children") { + // Other content - check children for enums and text nodes, and + // treat non-item content between items as continuation lines. + for child in node.children { + let child-items = flatten-all-enum-items(child) + if child-items.len() > 0 { + items = items + child-items + } else { + items = append-to-last-item(items, child) + } + } + } + + items + } + + // Extract and flatten all items + task-items = flatten-all-enum-items(body) + + // Determine starting number + let start-num = if resume { + tasks-counter.get().first() + 1 + } else { + start + } + + // Top-right zone to flow around (from the shared state unless overridden) + let zone = if wrap-zone == auto { taskize-wrap-zone.get() } else { wrap-zone } + + let render-split = (items, cols, start2, above2, below2) => render-tasks-grid( + items, cols, fmt, col-gut, row-gut, + lbl-width, lbl-align, lbl-baseline, lbl-weight, indent-after, + blk-indent, above2, below2, + flow-dir, start2, + adaptive-rows: adaptive-rows, + ) + + if task-items.len() > 0 and zone != none and flow-dir == "horizontal" { + // Flow around the reserved top-right zone: render whole rows in a + // narrowed box until the zone height is cleared, then the remaining + // rows at full width. Vertical flow keeps the plain layout (splitting + // it would reorder the numbering). + let zone-w = zone.width.to-absolute() + let zone-h = zone.height.to-absolute() + layout(size => { + let narrow = calc.max(size.width - zone-w, size.width * 0.35) + let cols = if _is-auto-fit-columns(requested-cols) { + _select-auto-fit-columns( + task-items, max-cols, fmt, col-gut, lbl-width, lbl-weight, + indent-after, blk-indent, start-num, fit-mode, fit-tolerance, narrow, + ) + } else { + requested-cols + } + let n = task-items.len() + let measure-first(k) = measure( + box(width: narrow, render-split(task-items.slice(0, k), cols, start-num, 0pt, 0pt)), + ).height + let k = calc.min(cols, n) + while k < n and measure-first(k) < zone-h { + k += cols + } + k = calc.min(k, n) + let first-height = measure-first(k) + block(width: 100%, above: above-spacing, below: below-spacing, { + box(width: narrow, render-split(task-items.slice(0, k), cols, start-num, 0pt, 0pt)) + if k < n { + v(row-gut) + render-split(task-items.slice(k, n), cols, start-num + k, 0pt, 0pt) + } else if first-height < zone-h { + // All items fit beside the zone: pad so following content clears it + v(zone-h - first-height) + } + }) + }) + // Consumed: a later #tasks call must not flow around the same zone + taskize-wrap-zone.update(none) + } else if task-items.len() > 0 { + if _is-auto-fit-columns(requested-cols) { + assert(fit-mode in ("fill", "uniform"), message: "auto-fit-mode must be \"fill\" or \"uniform\"") + layout(size => { + let cols = _select-auto-fit-columns( + task-items, max-cols, fmt, col-gut, lbl-width, lbl-weight, + indent-after, blk-indent, start-num, fit-mode, fit-tolerance, size.width, + ) + // In fill mode, compute the minimum span each regular item needs to fit + // at the chosen column count, so wide items auto-span rather than forcing + // a lower column count. + let render-items = if fit-mode == "fill" { + let lw = _compute-label-width(task-items, fmt, lbl-width, lbl-weight, indent-after, start-num) + let tol = _resolve-length(fit-tolerance) + task-items.map(item-data => { + let content = item-data.at(0) + let explicit-span = item-data.at(1) + if explicit-span > 1 { return item-data } + let base-h = _task-height-at-width(content, + _task-content-width(size.width, 1, 1, lw, col-gut, indent-after, blk-indent)) + for s in range(1, cols + 1) { + let w = _task-content-width(size.width, cols, s, lw, col-gut, indent-after, blk-indent) + if _task-height-at-width(content, w) <= base-h + tol { + return (content, s) + } + } + (content, cols) + }) + } else { + task-items + } + render-tasks-grid( + render-items, cols, fmt, col-gut, row-gut, + lbl-width, lbl-align, lbl-baseline, lbl-weight, indent-after, + blk-indent, above-spacing, below-spacing, + flow-dir, start-num, + adaptive-rows: adaptive-rows, + ) + }) + } else { + render-tasks-grid( + task-items, requested-cols, fmt, col-gut, row-gut, + lbl-width, lbl-align, lbl-baseline, lbl-weight, indent-after, + blk-indent, above-spacing, below-spacing, + flow-dir, start-num, + adaptive-rows: adaptive-rows, + ) + } + } +} + +// ============================================================================= +// Convenience Functions +// ============================================================================= + +// Reset the tasks counter +#let tasks-reset() = { + tasks-counter.update(0) +} + +// Shorthand for specific column counts +#let tasks2(body, label: auto, start: 1, resume: false) = { + tasks(columns: 2, label: label, start: start, resume: resume, body) +} + +#let tasks3(body, label: auto, start: 1, resume: false) = { + tasks(columns: 3, label: label, start: start, resume: resume, body) +} + +#let tasks4(body, label: auto, start: 1, resume: false) = { + tasks(columns: 4, label: label, start: start, resume: resume, body) +} diff --git a/packages/preview/taskize/0.2.8/typst.toml b/packages/preview/taskize/0.2.8/typst.toml new file mode 100644 index 0000000000..58e640bc48 --- /dev/null +++ b/packages/preview/taskize/0.2.8/typst.toml @@ -0,0 +1,13 @@ +[package] +name = "taskize" +version = "0.2.8" +entrypoint = "lib.typ" +authors = ["Nathan Scheinmann"] +license = "MIT" +description = "Horizontal and vertical columned lists for exercises and tasks, similar to LaTeX's tasks package" +repository = "https://github.com/nathan-ed/typst-package-taskize" +keywords = ["tasks", "exercises", "list", "columns", "horizontal", "layout", "education"] +categories = ["layout", "utility"] +disciplines = ["mathematics", "education"] +compiler = "0.14.0" +exclude = ["docs/*", "gallery", "tests", "*.pdf"]