From 20990e827f8d50f2789c4b489859684003b2e7be Mon Sep 17 00:00:00 2001 From: Nathan <22835124+nathan-ed@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:05:18 +0200 Subject: [PATCH 1/3] exercise-bank:0.7.0 --- packages/preview/exercise-bank/0.7.0/LICENSE | 21 + .../preview/exercise-bank/0.7.0/README.md | 1447 +++++++ .../0.7.0/gallery/badge-position.svg | 1 + .../0.7.0/gallery/badge-position.typ | 31 + .../0.7.0/gallery/badge-size.svg | 1 + .../0.7.0/gallery/badge-size.typ | 26 + .../exercise-bank/0.7.0/gallery/bank.svg | 1 + .../exercise-bank/0.7.0/gallery/bank.typ | 33 + .../exercise-bank/0.7.0/gallery/basic.svg | 1 + .../exercise-bank/0.7.0/gallery/basic.typ | 17 + .../0.7.0/gallery/competencies.svg | 1 + .../0.7.0/gallery/competencies.typ | 26 + .../0.7.0/gallery/corrections-only.svg | 1 + .../0.7.0/gallery/corrections-only.typ | 49 + .../0.7.0/gallery/corrections.svg | 1 + .../0.7.0/gallery/corrections.typ | 50 + .../0.7.0/gallery/difficulty.svg | 1 + .../0.7.0/gallery/difficulty.typ | 54 + .../0.7.0/gallery/draft-mode-1.svg | 1 + .../0.7.0/gallery/draft-mode-2.svg | 1 + .../0.7.0/gallery/draft-mode-3.svg | 1 + .../0.7.0/gallery/draft-mode.typ | 80 + .../0.7.0/gallery/end-chapter.svg | 1 + .../0.7.0/gallery/end-chapter.typ | 38 + .../0.7.0/gallery/end-section.svg | 1 + .../0.7.0/gallery/end-section.typ | 27 + .../exercise-bank/0.7.0/gallery/filtering.svg | 1 + .../exercise-bank/0.7.0/gallery/filtering.typ | 47 + .../0.7.0/gallery/inline-solutions.svg | 1 + .../0.7.0/gallery/inline-solutions.typ | 36 + .../0.7.0/gallery/linked-solutions.svg | 1 + .../0.7.0/gallery/linked-solutions.typ | 49 + .../exercise-bank/0.7.0/gallery/markers.svg | 1 + .../exercise-bank/0.7.0/gallery/markers.typ | 53 + .../0.7.0/gallery/mixed-display.svg | 1 + .../0.7.0/gallery/mixed-display.typ | 56 + .../0.7.0/gallery/pagebreak-1.svg | 1 + .../0.7.0/gallery/pagebreak-2.svg | 1 + .../0.7.0/gallery/pagebreak-3.svg | 1 + .../exercise-bank/0.7.0/gallery/pagebreak.typ | 29 + .../exercise-bank/0.7.0/gallery/qr-codes.svg | 1 + .../exercise-bank/0.7.0/gallery/qr-codes.typ | 42 + .../0.7.0/gallery/references-1.svg | 1 + .../0.7.0/gallery/references-2.svg | 1 + .../0.7.0/gallery/references.typ | 17 + .../0.7.0/gallery/section-numbering.svg | 1 + .../0.7.0/gallery/section-numbering.typ | 24 + .../exercise-bank/0.7.0/gallery/solutions.svg | 1 + .../exercise-bank/0.7.0/gallery/solutions.typ | 36 + .../exercise-bank/0.7.0/gallery/styles.svg | 1 + .../exercise-bank/0.7.0/gallery/styles.typ | 45 + .../0.7.0/gallery/titles-worked.svg | 1 + .../0.7.0/gallery/titles-worked.typ | 29 + .../0.7.0/gallery/two-columns.svg | 1 + .../0.7.0/gallery/two-columns.typ | 45 + packages/preview/exercise-bank/0.7.0/lib.typ | 3590 +++++++++++++++++ .../preview/exercise-bank/0.7.0/typst.toml | 13 + 57 files changed, 6039 insertions(+) create mode 100644 packages/preview/exercise-bank/0.7.0/LICENSE create mode 100644 packages/preview/exercise-bank/0.7.0/README.md create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/badge-position.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/badge-position.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/badge-size.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/badge-size.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/bank.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/bank.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/basic.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/basic.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/competencies.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/competencies.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/corrections-only.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/corrections-only.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/corrections.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/corrections.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/difficulty.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/difficulty.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/draft-mode-1.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/draft-mode-2.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/draft-mode-3.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/draft-mode.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/end-chapter.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/end-chapter.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/end-section.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/end-section.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/filtering.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/filtering.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/inline-solutions.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/inline-solutions.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/linked-solutions.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/linked-solutions.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/markers.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/markers.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/mixed-display.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/mixed-display.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/pagebreak-1.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/pagebreak-2.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/pagebreak-3.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/pagebreak.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/qr-codes.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/qr-codes.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/references-1.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/references-2.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/references.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/section-numbering.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/section-numbering.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/solutions.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/solutions.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/styles.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/styles.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/titles-worked.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/titles-worked.typ create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/two-columns.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/two-columns.typ create mode 100644 packages/preview/exercise-bank/0.7.0/lib.typ create mode 100644 packages/preview/exercise-bank/0.7.0/typst.toml diff --git a/packages/preview/exercise-bank/0.7.0/LICENSE b/packages/preview/exercise-bank/0.7.0/LICENSE new file mode 100644 index 0000000000..7081613f31 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Nathan + +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/exercise-bank/0.7.0/README.md b/packages/preview/exercise-bank/0.7.0/README.md new file mode 100644 index 0000000000..a798d20640 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/README.md @@ -0,0 +1,1447 @@ +# exercise-bank + +[![exercise-bank on Typst Universe](https://img.shields.io/badge/Typst_Universe-v._0.7.0-239dad?labelColor=eee)](https://typst.app/universe/package/exercise-bank) +[![Full package manual as PDF](https://img.shields.io/badge/Manual-pdf-333333?labelColor=eee)](https://github.com/nathan-ed/typst-package-exercise-bank/blob/aca0d3d07fe2718345ec800a2d1772212ec72261/docs/manual.pdf) +[![Distributed under the MIT license](https://img.shields.io/badge/License-MIT-333333?labelColor=eee)](LICENSE) + +A comprehensive Typst package for creating and managing exercises with solutions, metadata, filtering, and exercise banks. Perfect for teachers, textbook authors, and educational content creators. + +## Gallery + +Click on an image to see the source code. + +| | | | +|:---:|:---:|:---:| +| [![Basic exercises with automatic numbering](gallery/basic.svg)](gallery/basic.typ) | [![Exercises displayed with inline solutions](gallery/solutions.svg)](gallery/solutions.typ) | [![Exercise bank: define once, display anywhere](gallery/bank.svg)](gallery/bank.typ) | +| Basic Exercises | With Solutions | Exercise Bank | +| [![Filtering exercises by topic and metadata](gallery/filtering.svg)](gallery/filtering.typ) | [![Exercises tagged with competency indicators](gallery/competencies.svg)](gallery/competencies.typ) | [![Solutions collected and printed at end of section](gallery/end-section.svg)](gallery/end-section.typ) | +| Filtering by Topic | Competency Tags | Solutions at End | +| [![Teacher corrections with pedagogical notes](gallery/corrections.svg)](gallery/corrections.typ) | [![Mixed display mode combining solutions and corrections](gallery/mixed-display.svg)](gallery/mixed-display.typ) | [![Draft mode with placeholders for empty solutions](gallery/draft-mode-1.svg)](gallery/draft-mode.typ) | +| Teacher Corrections | Mixed Display Mode | Draft Mode | +| [![All 12 visual badge styles](gallery/styles.svg)](gallery/styles.typ) | [![Solutions displayed after a page break](gallery/pagebreak-1.svg)](gallery/pagebreak.typ) | [![Optional and correction-given exercise markers](gallery/markers.svg)](gallery/markers.typ) | +| Visual Styles | Solutions with Page Break | Exercise Markers | +| [![Per-exercise QR codes placed per badge style](gallery/qr-codes.svg)](gallery/qr-codes.typ) | [![Difficulty levels as colors, stars, or symbols](gallery/difficulty.svg)](gallery/difficulty.typ) | [![Corrections printed automatically at the end of each chapter](gallery/end-chapter.svg)](gallery/end-chapter.typ) | +| QR Codes | Difficulty Levels | End-of-Chapter Corrections | +| [![Clickable links between exercises and their corrections](gallery/linked-solutions.svg)](gallery/linked-solutions.typ) | [![Inline epigraph-style solutions with deferred corrections](gallery/inline-solutions.svg)](gallery/inline-solutions.typ) | [![Teacher answer key showing only corrections without exercise statements](gallery/corrections-only.svg)](gallery/corrections-only.typ) | +| Linked Corrections | Inline Solutions | Corrections Only | +| [![Badge placed above the statement instead of in a left margin column](gallery/badge-position.svg)](gallery/badge-position.typ) | [![Exercises and their corrections set on two columns with a vertical rule](gallery/two-columns.svg)](gallery/two-columns.typ) | [![The same badge shape at three sizes](gallery/badge-size.svg)](gallery/badge-size.typ) | +| Badge Position | Two-Column Layouts | Badge Size | +| [![Exercise titles and a worked example whose solution stays visible in the student version](gallery/titles-worked.svg)](gallery/titles-worked.typ) | [![Exercises numbered per section with solutions printed before each new section](gallery/section-numbering.svg)](gallery/section-numbering.typ) | | +| Titles and Worked Examples | Per-Section Numbering | | +| [![Clickable exercise references with their page and originating beautitled part](gallery/references-1.svg)](gallery/references.typ) | | | +| Exercise References | | | + +## Features + +- **Exercises with solutions** - Create exercises with inline or deferred solutions +- **12 visual styles** - Box, circled, filled-circle, rect, filled-rect, pill, tag, margin, border-accent, underline, rounded-box, header-card - or pass your own badge function +- **Customizable colors** - Set badge colors for any style +- **Badge position** - Keep the badge in its own left column (`"margin"`) or put it on a header line above a full-width statement (`"above"`) +- **Badge size** - Scale any badge shape with `badge-scale`, or set its padding and corner radius outright +- **Two-column layouts** - A whole two-column document, one columned block, or just the collected corrections - each with an optional vertical rule +- **Difficulty levels** - Encode up to 5 (or more) difficulty levels as badge colors, stars, or symbols +- **Exercise citations** - Cite a displayed exercise by number and page, optionally its beautitled part, from anywhere in the document +- **Clickable links** - Jump from an exercise to its deferred correction and back +- **Split solution/correction placement** - Short solution under the statement (epigraph-style), full correction at the end of the chapter +- **Chapter-prefixed numbering** - Number exercises as "3.5" using the current heading number, "3.2.5" with two heading levels, or a series number of your choice (`number-prefix: 3`) +- **Exercise titles** - "Exercise 1 – Pythagorean theorem" with `exo(title: ..)` +- **Worked examples** - `exo(worked: true)` shows its solution right under the statement, even in the student version +- **Compact headers** - `header-rule-gap` and `header-body-gap` tighten the space between the label, its rule and the statement +- **Automatic end-of-chapter corrections** - `#show: exo-auto-chapter` prints pending corrections before each new chapter +- **Teacher corrections** - Add detailed corrections for teachers +- **Flexible display modes** - Control what to show (exercises, solutions, or both) +- **Multiple location modes** - Show solutions inline, after page break, or at end of section/chapter +- **Draft mode** - Show placeholders for empty corrections/solutions, or hide them for clean student output +- **Metadata support** - Tag exercises with topic, level, author, and custom fields +- **Exercise banks** - Define exercises once, display them anywhere +- **Powerful filtering** - Select exercises by topic, level, competency, or custom criteria +- **Competency tags** - Tag and display competency indicators +- **Automatic numbering** - Counter resets per section, chapter, or globally +- **Customizable labels** - Change "Exercise" and "Solution" labels (localization support) +- **Exercise IDs** - Unique identifiers for referencing and bank management +- **Advanced exercise markers** - Visual cue (customizable symbol) for advanced/challenging exercises +- **Optional exercise markers** - Star icon for optional exercises, dumbbell icon for correction-given exercises +- **Exam display mode** - Show points in the exercise badge (`display-mode: "exam"`) without any external dependency +- **QR codes** - Attach a QR code per exercise (e.g. link to a video correction), placed automatically per badge style + +## Quick Start + +```typst +#import "@preview/exercise-bank:0.7.0": exo + +#exo( + exercise: [ + Solve the equation $2x + 5 = 13$. + ] +) +``` + +## Basic Usage + +### Simple Exercise + +```typst +#import "@preview/exercise-bank:0.7.0": exo + +#exo( + exercise: [ + Calculate $3 + 4 times 2$. + ] +) +``` + +### Exercise with Solution + +```typst +#import "@preview/exercise-bank:0.7.0": exo + +#exo( + exercise: [ + Calculate $3 + 4 times 2$. + ], + solution: [ + $3 + 4 times 2 = 3 + 8 = 11$ + ], +) +``` + +### Multiple Exercises + +```typst +#import "@preview/exercise-bank:0.7.0": exo + +#exo(exercise: [Simplify $x^2 + 2x + 1$.]) +#exo(exercise: [Factor $x^2 - 4$.]) +#exo(exercise: [Solve $2x - 6 = 0$.]) +``` + +## Display Control + +The package uses three parameters to control how exercises and solutions are displayed: + +### `show` - What to Display + +Controls what content is displayed: + +- `"both"` (default) - Show both exercises and solutions/corrections +- `"ex"` - Show only exercises (hide solutions/corrections) +- `"sol"` - Show only solutions/corrections (hide exercises) + +```typst +#import "@preview/exercise-bank:0.7.0": exo, exo-setup + +// Student worksheet - exercises only +#exo-setup(display: "ex") + +// Answer key - solutions only +#exo-setup(display: "sol") + +// Complete document - both +#exo-setup(display: "both") +``` + +### `corr-display` - What Content to Show + +Controls whether to show solutions or corrections: + +- `"solution"` (default) - Show the solution content +- `"correction"` - Show the correction content (for teachers) +- `"mixed"` - Default to solution, but show correction for exercises with `show-corr: true` + +```typst +#import "@preview/exercise-bank:0.7.0": exo, exo-setup + +// Student version - show solutions +#exo-setup(corr-display: "solution") + +// Teacher version - show corrections +#exo-setup(corr-display: "correction") + +// Mixed mode +#exo-setup(corr-display: "mixed") + +#exo( + exercise: [Solve $x^2 = 9$.], + solution: [$x = plus.minus 3$], + correction: [Detailed explanation with teaching notes...], + show-corr: true, // In mixed mode, show correction for this exercise +) +``` + +### `corr-loc` - Where to Display + +Controls where solutions/corrections appear: + +- `"after"` (default) - Show immediately after each exercise +- `"pagebreak"` - Show with a page break between exercise and solution +- `"end-section"` - Collect and show at section end +- `"end-chapter"` - Collect and show at chapter end + +**Important:** with `"end-section"` and `"end-chapter"`, the solutions are only *collected* - you decide where they appear by calling `#exo-section-end()` / `#exo-chapter-end()` (or `#exo-print-solutions()`) at that point, or by using `exo-auto-chapter` (see below) to do it automatically. + +```typst +#import "@preview/exercise-bank:0.7.0": exo, exo-setup, exo-print-solutions + +// Solutions at end of section +#exo-setup(corr-loc: "end-section") + +#exo(exercise: [Exercise 1], solution: [Answer 1]) +#exo(exercise: [Exercise 2], solution: [Answer 2]) + +// Print all collected solutions +#exo-print-solutions(title: "Answers") +``` + +### Automatic End-of-Chapter Corrections + +Instead of calling `#exo-chapter-end()` manually, wrap your document with `exo-auto-chapter`: the pending solutions/corrections are printed right before each new level-1 heading and at the end of the document, and the exercise counter resets at each chapter. + +```typst +#import "@preview/exercise-bank:0.7.0": exo, exo-setup, exo-auto-chapter + +#exo-setup(corr-loc: "end-chapter", counter-reset: "chapter") +#show: exo-auto-chapter + += Chapter 1 +#exo(exercise: [Exercise 1], solution: [Answer 1]) + += Chapter 2 // <- Chapter 1 solutions are printed just before this title +#exo(exercise: [Exercise 2], solution: [Answer 2]) +// <- Chapter 2 solutions are printed at the end of the document +``` + +### `sol-loc` - Separate Solution and Correction Placement + +`sol-loc` controls where *solutions* go, independently of corrections (default `auto` = follow `corr-loc`). A typical setup: the short answer right below the statement, the full correction at the end of the chapter. + +```typst +#import "@preview/exercise-bank:0.7.0": exo, exo-setup, exo-auto-chapter + +#exo-setup( + corr-display: "correction", // show both the correction and the solution + sol-loc: "after", // short answer below the statement + corr-loc: "end-chapter", // full correction at the end of the chapter + solution-style: "inline", // optional: epigraph-like short rule, no badge +) +#show: exo-auto-chapter + += Chapter 1 +#exo( + exercise: [Solve $x^2 - 5x + 6 = 0$.], + solution: [$x in {2, 3}$], + correction: [Factor: $(x-2)(x-3) = 0$, so $x = 2$ or $x = 3$.], +) +``` + +With `solution-style: "inline"`, the solution appears under the statement after a short horizontal rule (like an epigraph) instead of a badge box. A small label in the margin (the solution label, in italics) makes clear it is the solution; customize it with `inline-label` (any content, or `none` to hide it). The rule length is configurable via `inline-rule-length` (default `3cm`). + +### Clickable Exercise <-> Correction Links + +When corrections are deferred (`pagebreak`, `end-section`, `end-chapter`), enable `link-solutions` to get a small clickable arrow next to the exercise badge that jumps to its correction, and a back-arrow on the correction that returns to the statement - handy for students navigating a PDF. + +```typst +#exo-setup(corr-loc: "end-chapter", link-solutions: true) +``` + +The icons are customizable (`link-icon`, `backlink-icon`; set to `none` to hide one side). Exercises without a deferred solution or correction get no icon. + +Prefer the textbook look? With `link-style: "page"`, the exercise instead shows a clickable "Solution p. 30"-style reference at the top right of the statement, pointing to the page where the correction was printed: + +```typst +#exo-setup( + corr-loc: "end-chapter", + link-solutions: true, + link-style: "page", +) +``` + +The reference uses the solution/correction label and the badge color; override with `page-ref-color` or a full `page-ref-format: (label, page) => content` function. + +## Corrections (Teacher Version) + +Corrections are detailed solutions for teachers, including pedagogical notes and teaching tips. + +### Exercise with Correction + +```typst +#import "@preview/exercise-bank:0.7.0": exo, exo-setup + +#exo-setup(corr-display: "correction") + +#exo( + exercise: [Solve $x^2 = 9$.], + correction: [ + *Teacher's notes:* + $x = plus.minus 3$ + + Common mistake: Students often forget the negative root. + ], +) +``` + +### Corrections Only (Teacher Answer Key) + +Create teacher answer keys showing only corrections: + +```typst +#import "@preview/exercise-bank:0.7.0": exo, exo-setup + +#exo-setup( + display: "sol", // Only show solutions/corrections + corr-display: "correction", // Show corrections +) + +#exo( + exercise: [Exercise 1 (hidden in output)], + correction: [Detailed correction for teachers], +) +``` + +### Mixed Display Mode + +Use `corr-display: "mixed"` to default to solutions while showing corrections for specific exercises: + +```typst +#import "@preview/exercise-bank:0.7.0": exo, exo-setup + +#exo-setup(corr-display: "mixed") + +// This shows solution (default) +#exo( + exercise: [Simple problem], + solution: [Quick answer], + correction: [Detailed explanation], +) + +// This shows correction (because show-corr: true) +#exo( + exercise: [Complex problem needing explanation], + solution: [Answer], + correction: [Detailed step-by-step solution with notes], + show-corr: true, +) +``` + +### Exercise-Level Flags + +- `show-corr: true` - In "mixed" mode, show correction instead of solution for this exercise +- `sol-in-corr: true` - Indicates that the correction already includes the solution; in "correction" mode, only correction is shown (not both correction AND solution) +- `worked: true` - Worked example: the solution/correction is always shown right after the statement (see below) + +### Worked Examples + +A worked example keeps its solution under the statement whatever the document-wide settings say: with `display: "ex"` (student version), with a deferred `corr-loc` (`"end-section"`, `"end-chapter"`, `"pagebreak"`), and with `corr-display: "solution"` when the exercise only has a correction. + +```typst +#exo-setup(display: "ex") + +#exo( + title: [Worked example], + worked: true, + exercise: [Simplify $e^3 times e^4$.], + solution: [$e^3 times e^4 = e^(3+4) = e^7$], +) +#exo(exercise: [Simplify $e^5 times e^(-2)$.], solution: [$e^3$]) // hidden +``` + +### Draft Mode and Placeholders + +When creating exercise documents, you may have incomplete corrections or solutions. Draft mode allows you to: +- Show placeholder text for empty corrections/solutions (useful for teacher drafts) +- Maintain exercise counters even with empty content +- Hide placeholders in student versions + +```typst +#import "@preview/exercise-bank:0.7.0": exo, exo-setup + +// Teacher draft version - shows placeholders +#exo-setup( + draft-mode: true, + correction-placeholder: [_[To be completed]_], + solution-placeholder: [_[Answer to be written]_], +) + +#exo( + exercise: [Solve $x + 5 = 12$], + solution: [], // Empty - shows placeholder in draft mode +) + +// Student version (draft mode OFF) +#exo-setup(draft-mode: false) + +#exo( + exercise: [Solve $x + 5 = 12$], + solution: [], // Empty - shows only space, no placeholder +) +``` + +## Metadata and Filtering + +### Adding Metadata + +Tag exercises with metadata for organization and filtering: + +```typst +#import "@preview/exercise-bank:0.7.0": exo + +#exo( + exercise: [Solve $x + 1 = 5$.], + topic: "algebra", + level: "easy", + authors: ("Prof. Smith",), +) +``` + +### Filtering Exercises + +Display only exercises matching certain criteria: + +```typst +#import "@preview/exercise-bank:0.7.0": exo, exo-filter + +// First, define exercises (they display normally) +#exo(exercise: [Exercise 1], topic: "algebra") +#exo(exercise: [Exercise 2], topic: "geometry") +#exo(exercise: [Exercise 3], topic: "algebra") + +// Later, filter and redisplay specific exercises +#exo-filter(topic: "algebra") // Shows exercises 1 and 3 +``` + +## Exercise Banks + +Define exercises once, use them anywhere. Perfect for creating exercise collections. + +### Defining Bank Exercises + +```typst +#import "@preview/exercise-bank:0.7.0": exo-define + +// These don't display - just registered +#exo-define( + id: "quad-1", + exercise: [Solve $x^2 - 5x + 6 = 0$.], + topic: "quadratics", + level: "1M", + solution: [$x = 2$ or $x = 3$], +) + +#exo-define( + id: "geom-1", + exercise: [Find the area of a circle with radius 5.], + topic: "geometry", + level: "1M", + solution: [$A = pi r^2 = 25pi$], +) +``` + +### Displaying Bank Exercises + +```typst +#import "@preview/exercise-bank:0.7.0": exo-show, exo-show-many + +// Show a single exercise by ID +#exo-show("quad-1") + +// Show multiple exercises +#exo-show-many("quad-1", "geom-1", "quad-2") +``` + +### Selecting from Bank + +Use powerful filtering to select exercises: + +```typst +#import "@preview/exercise-bank:0.7.0": exo-select + +// All quadratics exercises +#exo-select(topic: "quadratics") + +// Level 1M exercises only +#exo-select(level: "1M") + +// Multiple topics +#exo-select(topics: ("quadratics", "geometry")) + +// Limit number of exercises +#exo-select(topic: "algebra", max: 5) + +// Custom filter function +#exo-select(where: ex => ex.metadata.level == "hard") +``` + +## Competency Tags + +Tag exercises with competencies and display them visually: + +```typst +#import "@preview/exercise-bank:0.7.0": exo-define, exo-show, exo-setup + +#exo-setup(show-competencies: true) + +#exo-define( + id: "comp-ex-1", + exercise: [Solve and explain your reasoning.], + competencies: ("C1.1", "C2.3", "C4.1"), + solution: [Solution here], +) + +#exo-show("comp-ex-1") +``` + +### Filter by Competency + +```typst +#import "@preview/exercise-bank:0.7.0": exo-select + +// Exercises with specific competency +#exo-select(competency: "C1.1") + +// Exercises with any of these competencies +#exo-select(competencies: ("C1.1", "C2.3")) +``` + +## Configuration + +### Global Setup + +```typst +#import "@preview/exercise-bank:0.7.0": exo-setup + +#exo-setup( + // Display control + display: "both", // "ex", "sol", "both" + corr-display: "solution", // "solution", "correction", "mixed" + corr-loc: "after", // "after", "pagebreak", "end-section", "end-chapter" + // Labels + exercise-label: "Exercise", + solution-label: "Solution", + correction-label: "Correction", + // Counter behavior + counter-reset: "section", // "section", "chapter", "global" + // Display options + show-metadata: false, + show-id: false, + show-competencies: false, + // Draft mode + draft-mode: false, + correction-placeholder: [_To be completed_], + solution-placeholder: [_To be completed_], + // Spacing + exercise-above: 0.8em, + exercise-below: 0.8em, + solution-above: 0.8em, + solution-below: 0.8em, + correction-above: 0.8em, + correction-below: 0.8em, + // Advanced exercises + advanced-symbol: "*", +) +``` + +### Localization + +Change labels for different languages: + +```typst +#import "@preview/exercise-bank:0.7.0": exo-setup + +// French +#exo-setup( + exercise-label: "Exercice", + solution-label: "Solution", + correction-label: "Corrigé", +) + +// German +#exo-setup( + exercise-label: "Aufgabe", + solution-label: "Lösung", +) +``` + +### Visual Styles + +Choose from 12 different badge styles: + +```typst +#import "@preview/exercise-bank:0.7.0": exo, exo-setup + +// Circled number style +#exo-setup(badge-style: "circled") + +// Filled circle with custom color +#exo-setup(badge-style: "filled-circle", badge-color: rgb("#2563eb")) + +// Tag style +#exo-setup(badge-style: "tag", badge-color: rgb("#1e40af")) + +// Custom colors for solutions and corrections +#exo-setup( + solution-color: rgb("#059669"), // Green for solutions + correction-color: rgb("#dc2626"), // Red for corrections +) + +#exo(exercise: [Solve $x + 3 = 7$]) +``` + +Available styles: `"box"` (default), `"circled"`, `"filled-circle"`, `"rect"`, `"filled-rect"`, `"pill"`, `"tag"`, `"margin"`, `"border-accent"`, `"underline"`, `"rounded-box"`, `"header-card"` + +The `"rect"` and `"filled-rect"` styles show a compact number-only rectangle - a minimal alternative to the circle styles when circles look too large in your font. + +### Badge Position + +By default the badge sits in its own column on the left, so the statement is indented for its whole height. In a narrow measure - two-column layouts especially - that column is expensive, and it gets worse when the statement holds an enumeration, whose indent stacks on top of it. + +`badge-position: "above"` puts the badge alone on a header line and lets the statement run the full width underneath: + +```typst +#exo-setup(badge-style: "filled-circle", badge-position: "above") +``` + +The setting is independent of `badge-style`, so every badge look above keeps working. When `link-style: "page"` is on, the "Solution p. 34" reference moves onto the badge line as well instead of being wrapped into the statement. + +#### Custom badge function + +For full control without touching the package, pass a function as `badge-style`. It receives `(label, number, font-size, color, is-solution)` and returns the badge content: + +```typst +#exo-setup(badge-style: (label, number, font-size, color, is-solution) => { + box(stroke: (bottom: 1.5pt + color), inset: (x: 4pt, y: 3pt), + text(weight: "bold", size: font-size, fill: color)[#number.]) +}) +``` + +#### Label margin width + +For badge styles, the content is indented by `margin-position` so that exercise, solution, and correction boxes all align. By default (`auto`) it is computed from your configured labels with a 3-digit number (e.g. "Correction 100"). Reduce it for a tighter layout: + +```typst +#exo-setup(margin-position: 1.6cm) // narrower label column +#exo-setup(label-extra: 0pt) // don't extend labels into the page margin +``` + +If a badge (or QR code) is wider than the configured margin, the label column widens for that box instead of overflowing. + +### Badge Size + +Every badge shape ships with paddings and a corner radius tuned for a 12pt label. `badge-scale` multiplies them - below 1 the badge closes in on the number, above 1 it grows into a block: + +```typst +#exo-setup(badge-style: "filled-rect", badge-scale: 0.6) // discreet +#exo-setup(badge-style: "pill", badge-scale: 1.4) // roomy +``` + +The label text keeps the size set by `label-font-size`: scaling the badge changes the box, not the digits, so lower both together for a genuinely small badge. For full control, `badge-pad-x`, `badge-pad-y` and `badge-radius` replace the shape's own values outright (and ignore `badge-scale`). The badge height is always `label-font-size + 2 * badge-pad-y`: + +```typst +#exo-setup( + badge-style: "pill", + label-font-size: 9pt, + badge-pad-x: 6pt, // horizontal padding + badge-pad-y: 2pt, // -> 13pt tall + badge-radius: 6.5pt, // half the height: still a pill, just a smaller one +) +``` + +The label column follows the badge as it is resized, so `badge-position: "margin"` keeps its alignment without touching `margin-position`. Custom badge functions draw their own geometry and are left alone by all four settings. + +### Exercise Titles + +```typst +#exo( + title: [Calculer une longueur avec le théorème de Pythagore], + exercise: [...], +) +``` + +The full-width styles (`border-accent`, `underline`, `rounded-box`, `header-card`) put the title in their header line after `title-separator` (default an en dash): "Exercice 1 – Calculer une longueur…". The badge styles show it in bold next to the badge, and `margin` opens the statement with it. `title-format` takes a function `(title) => content` to restyle it, and `title-in-solutions: true` repeats it on the solution and correction boxes. `exo-define` accepts `title` too. + +### Header Spacing + +The header of the full-width styles and of `badge-position: "above"` can be tightened (or loosened): + +```typst +#exo-setup( + badge-style: "underline", + header-rule-gap: 0.25em, // "Exercice 1" -> rule (underline only) + header-body-gap: 0.5em, // header -> statement +) +``` + +Both default to `auto`, the style's own spacing. For `underline`, the default +geometry now stays independent of the document's `par.spacing`; paragraph spacing +inside the statement is still inherited. + +`underline-gap` and `underline-below` override these two settings for the +`underline` style only, on exercises, solutions and corrections (including +`display: "sol"` and deferred corrections). `none` leaves the current setting +unchanged; `auto` returns to `header-rule-gap` / `header-body-gap`. + +- `underline-gap` measures from the lower edge of the title's text block to the + **centre** of the 0.8pt rule. With Typst's default `text(bottom-edge: "baseline")`, + this is the last title line's baseline, even for wrapped titles. +- `underline-below` measures from the rule's **centre** to the top of the body + block. The visible stroke extends 0.4pt above and below its centre. Body insets, + leading spacing and font ink metrics can change the visible white space. + +With both general settings at `auto`, the defaults reproduce the former layout +with `par.spacing: 1.2em`: the upper gap is +`1.2 * calc.max(label-font-size + 1pt, text.size) - 0.3em`, and the lower gap is +`1.7em`. Here `em` uses the surrounding text size. + +For MathALÉA at 11pt (a rule centre 2.2pt below the title baseline, and about +0.78em of white space below the stroke for a body starting with plain text): + +```typst +#exo-setup( + badge-style: "underline", + underline-gap: 0.2em, + underline-below: 0.78em + 0.4pt, // 8.98pt from rule centre to body top at 11pt + solution-above: 1.8em, +) +``` + +On Typst 0.13+, `underline`, `border-accent`, `rounded-box` and `header-card` +keep their header with the first line of the body at page and column breaks. +The body remains breakable; older compilers retain the non-sticky fallback. + +### Counter Reset Options + +Control when exercise numbering resets: + +```typst +#import "@preview/exercise-bank:0.7.0": exo-setup, exo-section-start, exo-chapter-start + +// Reset at each section +#exo-setup(counter-reset: "section") += Section 1 +#exo-section-start() + +// Reset at each chapter +#exo-setup(counter-reset: "chapter") += Chapter 1 +#exo-chapter-start() + +// Never reset (global numbering) +#exo-setup(counter-reset: "global") +``` + +### Chapter-Prefixed Numbering + +With `number-prefix: "heading"`, the displayed exercise number is prefixed by the current level-1 heading number, e.g. exercise 5 of chapter 3 shows as "3.5" (on the exercise, its solution, and its correction): + +```typst +#set heading(numbering: "1.") +#exo-setup(number-prefix: "heading", counter-reset: "chapter") +#show: exo-auto-chapter // or call #exo-chapter-start() at each chapter + += Equations +#exo(exercise: [Numbered 1.1]) +#exo(exercise: [Numbered 1.2]) +``` + +The separator is configurable with `number-separator` (default `"."`). + +For per-chapter or per-section numbering, the simplest is `number-prefix: "chapter"` or `"section"` together with `exo-auto-chapter`. Nothing else to call: + +```typst +#show: exo-auto-chapter +#exo-setup(number-prefix: "section", corr-loc: "end-section") + += Equations // chapter 1 +== Linear // exercises 1.1.1, 1.1.2, ... - solutions printed before the next section +== Quadratic // exercises 1.2.1, ... +``` + +- `"chapter"` gives "3.5", `"section"` gives "3.2.5" (just "3.5" before the first section of a chapter). +- `exo-auto-chapter` restarts the numbering at each chapter, and at each section when the prefix is `"section"` (force with `exo-auto-chapter.with(sections: true)`). Before every chapter or section heading it prints the pending `"end-section"` corrections, and before every chapter the `"end-chapter"` ones. +- **beautitled**: both keywords read beautitled's own chapter and section counters, and `exo-auto-chapter` follows `enable-parts` (with parts, `==` is the chapter and `===` the section). No `chapter-counter` import is needed. + +`number-prefix-depth: 2` with `number-prefix: "heading"` takes two levels of `counter(heading)` directly: exercise 5 of section 3.2 shows as "3.2.5" (without `exo-auto-chapter`, reset the counter at each section with `exo-section-start()` or `exo-reset-counter()`). + +`number-prefix` also accepts a **literal value**, for a series or worksheet number. Setting one restarts the numbering: + +```typst +#exo-setup(number-prefix: 3) // Série 3 +#exo(exercise: [...]) // 3.1 +#exo(exercise: [...]) // 3.2 +#exo-setup(number-prefix: 4) // 4.1, 4.2, ... +``` + +`number-prefix` also accepts a **counter** or a **function** `() => value`, for heading packages that keep their own chapter counter instead of `counter(heading)`. + +Works with [beautitled](https://typst.app/universe/package/beautitled): `number-prefix: "chapter"` / `"section"` read its counters directly, parts included. From beautitled 0.3.0 the native heading counter is also kept in sync, so `number-prefix: "heading"` works too (without parts: with `enable-parts: true` the first heading level is the part, so prefer `"chapter"`). With beautitled's *direct function calls* (`#chapter(...)` instead of `= headings`), `exo-auto-chapter` has no heading to hook onto - wrap the chapter call instead: + +```typst +#let chapitre(..args) = { exo-chapter-end(); chapter(..args); exo-chapter-start() } +``` + +### Show Exercise IDs + +Display exercise IDs for reference: + +```typst +#import "@preview/exercise-bank:0.7.0": exo-setup, exo + +#exo-setup(show-id: true) + +#exo( + id: "my-exercise", + exercise: [ + This exercise shows its ID below the badge. + ] +) +``` + +### Advanced Exercises + +Mark exercises as advanced to display a visual cue before the label: + +```typst +#import "@preview/exercise-bank:0.7.0": exo, exo-setup + +// Default symbol is "*" +#exo( + exercise: [This is a challenging problem.], + advanced: true, +) + +// Use a custom symbol +#exo-setup(advanced-symbol: sym.dagger) +#exo(exercise: [Advanced with dagger.], advanced: true) + +// Disable the feature +#exo-setup(advanced-symbol: none) +``` + +### Optional Exercises + +Mark exercises as optional — a star icon appears before the label: + +```typ +#exo( + exercise: [Skip this if you're short on time. Solve $x^2 - x - 6 = 0$.], + optional: true, +) +``` + +Customize or disable the symbol: `exo-setup(optional-symbol: [⭐])` / `exo-setup(optional-symbol: none)`. + +### Correction-Given Exercises + +A dumbbell icon signals that the printed correction will be distributed: + +```typ +#exo( + exercise: [Factor $x^2 - 9$.], + solution: [$(x-3)(x+3)$], + corr-given: true, +) +``` + +Customize via `exo-setup(corr-given-symbol: ...)` or disable with `none`. + +### Difficulty Levels + +Tag each exercise with a `difficulty:` level. The built-in scale has 5 levels; how it shows is controlled by `difficulty-display`: + +- `"color"` (default) - the exercise badge takes the level color: green, red, blue, purple, black +- `"stars"` - 1 to 5 small stars before the label (numeric levels) +- `"symbols"` - one icon per level: seedling, pencil, target, mountain, star +- `"none"` - metadata only (still usable for filtering) + +```typst +#exo(exercise: [Introductory.], difficulty: 1) +#exo(exercise: [Exam-type.], difficulty: 3) +#exo(exercise: [Advanced.], difficulty: 4) + +// Stars or symbols instead of colors +#exo-setup(difficulty-display: "stars") +#exo-setup(difficulty-display: "symbols") +``` + +Stars and symbols are placed *below* the badge by default so the badge stays compact; use `difficulty-position: "badge"` to put them inline before the label instead. + +The scale is fully customizable - any keys, any colors, any symbols: + +```typst +#exo-setup(difficulty-scale: ( + "easy": (color: rgb("#00897b")), + "hard": (color: rgb("#e65100"), symbol: [🔥]), +)) +#exo(exercise: [...], difficulty: "hard") +``` + +Difficulty combines well with the `optional` marker: encode every exercise's level, and use `optional: false/true` to mark which ones are mandatory. You can also filter by difficulty: + +```typst +#exo-select(difficulty: 3) // one level +#exo-select(difficulties: (1, 2)) // any of these levels +#exo-count(difficulty: 4) +``` + +### QR Codes + +Attach a QR code to any exercise (e.g. linking to a video correction or an online version). Pass a URL string — the code is generated with [tiaoma](https://typst.app/universe/package/tiaoma/) — or ready-made content: + +```typst +#exo( + exercise: [Solve $2x + 5 = 13$.], + qr: "https://example.com/corrections/exo-1", +) + +// Works in banks too: the QR is stored and shown by exo-show / exo-select +#exo-define(id: "eq1", exercise: [...], qr: "https://example.com/eq1") +#exo-show("eq1") + +// Solutions and corrections can carry their own QR code (e.g. a video walkthrough) +#exo( + exercise: [...], + solution: [...], + qr-sol: "https://example.com/videos/sol-1", // QR on the solution box + correction: [...], + qr-corr: "https://example.com/videos/corr-1", // QR on the correction box +) +``` + +The placement adapts to the badge style: + +- **Badge styles** (`box`, `circled`, `filled-circle`, `pill`, `tag`): the QR sits below the badge in the label margin, and shrinks automatically if you reduce `margin-position`. +- **`margin` style**: the QR sits below the side label in the margin column. +- **Full-width styles** (`border-accent`, `underline`, `rounded-box`, `header-card`): the exercise content wraps around the QR at the top right (via [wrap-it](https://typst.app/universe/package/wrap-it/)). + +Global options: + +```typst +#exo-setup( + qr-size: 1.5cm, // Target size (default 1.5cm) + qr-min-size: 1cm, // Never shrink below this — a smaller QR is hard to + // scan; past that it extends into the page margin + qr-color: rgb("#1e3a8a"), // Module color (default black) + qr-caption: [Corrigé], // Small caption below every QR (default none) + qr-position: "wrap", // "auto" (default, placed per badge style), "wrap" + // (always wrap the exercise content around the QR), or + // "tasks" (overlay the QR; a taskize #tasks body flows + // around it instead of the whole block being pushed down) + show-qr: false, // Master toggle, e.g. for a print version +) +``` + +`qr-position: "tasks"` is opt-in and coordinates with [taskize](https://typst.app/universe/package/taskize/) (0.2.8+) purely through a shared state key — no import dependency in either direction. It only takes effect when the exercise body contains a `#tasks(...)` call; other bodies render as if unset. + +## Multi-Column Layouts + +Exercise sheets often read better on two columns. Typst's `columns` does the flowing but draws no separator, and it leaves the badge column at its full width - a large share of a narrow measure. The package wraps both concerns, and applies them to the corrections as well. + +### A Whole Two-Column Document + +`exo-page-columns` is a show rule: page columns, an optional vertical rule in the middle of every gutter, and (unless told otherwise) badges moved above the statements so they cost no column width. The text flows from page to page as usual and the rule is redrawn on every page. + +```typst +#show: exo-page-columns.with(count: 2, rule: 0.5pt + gray) + +#exo(exercise: [...], solution: [...]) +``` + +Put the show rule after `#set page(...)`: it reads the page geometry to place the rule inside the text area. `gutter`, `rule` and `rule-inset` are all configurable. The rule is page furniture here - drawn across the full text area of every page, including a last page the exercises only half fill. That is deliberate for a document set in columns throughout; `rule-inset` trims a fixed amount off both ends, and `exo-columns` (whose grid stops the rule at the content) is the better fit for a single block. + +> **Order matters with `exo-auto-chapter`.** Show rules nest in the order they are written, so `exo-page-columns` must come first: +> +> ```typst +> #show: exo-page-columns.with(count: 2, rule: 0.5pt + gray) +> #show: exo-auto-chapter +> ``` +> +> The other way round, the corrections `exo-auto-chapter` prints at the end of the document sit outside the columned body and come out full width, silently. `corr-columns` (below) wraps the corrections where they are printed, so it holds either way. + +### One Columned Block + +`exo-columns` puts a single block on several columns while the rest of the document stays full width: + +```typst +#exo-columns(count: 2, rule: 0.5pt + gray, gutter: 0.7cm)[ + #exo(exercise: [Solve $2x + 3 = 13$.], solution: [$x = 5$]) + #exo(exercise: [Factor $x^2 - 9$.], solution: [$(x-3)(x+3)$]) +] +``` + +By default the block is `columns` inside a block whose height is estimated from the content (`slack` widens the estimate, `height` sets it outright). The estimate is also a cap: a body that outgrows it is cut off, and a body longer than a page is lost. + +`balance: true` takes a different route - the body is split into its own pieces (one exercise is one piece) and handed to a grid, which draws the rule at the real height of the content and breaks across pages like any other block. Nothing is estimated and nothing is lost: + +```typst +#exo-columns(count: 2, rule: 0.5pt + gray, balance: true)[ + #for i in range(1, 60) { exo(exercise: [...]) } +] +``` + +It needs a rule, no explicit `height`, and a body it can take apart (a sequence of exercises; a `#block[..]` around everything falls back to the estimate). Counter updates travel with their exercise, so a split never renumbers anything. The columns are filled by piece count rather than measured height - every piece is a `context` element, and measuring those would leave the document short of converging - so one much taller piece leaves its column longer. + +It is opt-in because two ruled grids in one document (a split block **and** `corr-columns` with a rule) together with `exo-auto-chapter` can leave the document short of converging. One split block alongside ruled corrections is fine; several are not. + +Both functions take `badge-position`, applied to their own body only: `auto` (the default) uses `"above"`, `none` leaves your configuration alone, and an explicit value is used as given. + +### Corrections on Two Columns + +Collected corrections (`corr-loc: "end-chapter"` or `"end-section"`) are printed from inside the flow, so they cannot be wrapped in `exo-columns` by hand. `corr-columns` sets them on their own columns whatever the rest of the document does: + +```typst +#exo-setup( + corr-loc: "end-chapter", + corr-columns: 2, + corr-columns-gutter: 0.7cm, + corr-columns-rule: 0.5pt + gray, // optional vertical bar +) +#show: exo-auto-chapter +``` + +Two layouts hide behind that setting: + +- **Without a rule**, this is Typst's own `columns`: the corrections flow from one column into the next and on across pages. +- **With a rule**, they are laid out as a grid, which draws the bar itself at the true height of the content on every page the section spans. The corrections are then distributed up front instead of flowing from one column into the next, and by piece count rather than by measured height (measuring `context` elements would leave the document short of converging) - so one much taller correction leaves its column longer. + +`corr-columns-badge-position` decides where the badge sits in those columns: `auto` (default) uses `"above"` as soon as there are two columns or more; pass `"margin"` to keep the badges beside the corrections. `corr-columns-rule: none` removes a rule set earlier - that parameter's "leave as is" value is `auto`, unlike the rest of `exo-setup`, so that `none` can mean what it says. + +### Badge Styles in Narrow Columns + +The styles that wrap the whole exercise (`border-accent`, `underline`, `rounded-box`, `header-card`) already run the full width. The exception is `margin`, whose side label is a fixed column of its own: in a two-column measure it would leave the statement about as wide as the label. It folds instead - rule across the measure, label under it on the right, statement full width below - either when the measure drops under `margin-fold-below` (`auto` = three times the label column) or whenever `badge-position` is `"above"`, which is what `exo-columns` and `exo-page-columns` ask for by default. `margin-label-width` and `margin-label-gutter` set the unfolded geometry, and `margin-fold-below: 0pt` never folds. + +For a document that is *entirely* two-column, use `exo-page-columns` and leave `corr-columns` at 1: the corrections then flow with everything else, under the page rule. + +## Citing Exercises + +`exo-cite` creates a clickable reference to a displayed exercise, including its +actual number and page. It works before or after the target, including across +chapter files included in one compiled document. + +```typst +#import "@preview/exercise-bank:0.7.0": exo, exo-setup, exo-cite +#set heading(numbering: "1.") +#exo-setup(exercise-label: "Exercice", number-prefix: "chapter") + += First chapter +See #exo-cite("pythagoras") for a later exercise. + += Second chapter +#exo(id: "pythagoras", exercise: [Calculate the hypotenuse.]) +``` + +The reference uses the number printed on the selected occurrence, including +literal series prefixes or custom numbering, and the page of its header. +`exo-define` alone does not create a target: the exercise must be displayed by +`exo`, `exo-show`, `exo-select`, `exo-filter`, or an exercise `exo-box` with an ID. +Filtered selections, `where` and `max` are handled using the actual displayed +exercises, even when a selection spans pages. + +For beautitled documents with parts enabled, add `show-part: true`: + +```typst +See #exo-cite("pythagoras", show-part: true). +// For example: Exercice 1.2 (p. 12, Partie II) +``` + +The exercise number stays exactly as printed; the part is added separately. +The default word before the number, part label and part numbering are captured +at the target. Page references follow the target page's numbering pattern and +counter (including Roman numerals or page-counter resets). On Typst 0.15+, +contextual page numbering functions are evaluated at the target; older compilers +evaluate the numbering function in the citation context. + +If an ID appears several times, the first display in document order is cited +by default. Use `occurrence: 2` for its second display, or attach a label to a +specific display call: + +```typst +#exo-select(topic: "geometry") +See #exo-cite("pythagoras", ). +``` + +The optional second positional argument is also accepted as `pos-label:`. An +optional `topic:` restricts matching targets to that topic; it does not recompute +the exercise number. A missing ID, hidden exercise (`display: "sol"`), missing +display label or unavailable occurrence produces `Exercice ?? (p. ??)` without +a link. Exercises with automatically generated IDs can collide after counter +resets: use explicit unique IDs for references. Custom badges must render the +provided number to retain the reference anchor. + +Separate PDFs compiled independently do not share targets; include the chapter +files in one master document to resolve these references. + +## Utility Functions + +### Reset Counter + +```typst +#import "@preview/exercise-bank:0.7.0": exo-reset-counter + +#exo-reset-counter() // Reset exercise numbering to 0 +``` + +### Clear Registry + +```typst +#import "@preview/exercise-bank:0.7.0": exo-clear-registry + +#exo-clear-registry() // Clear all registered exercises +``` + +### Count Exercises + +```typst +#import "@preview/exercise-bank:0.7.0": exo-count + +Total algebra exercises: #exo-count(topic: "algebra") +Level 1M exercises: #exo-count(level: "1M") +``` + +## Parameters Reference + +### `exo` Function + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `exercise` | content | none | Exercise content (named parameter) | +| `solution` | content | none | Solution content | +| `correction` | content | none | Correction content (teacher version) | +| `id` | string/auto | auto | Unique exercise ID | +| `title` | content | none | Title shown after "Exercise 1" | +| `worked` | bool | false | Worked example: always show the solution/correction right after the statement | +| `sol-in-corr` | bool | false | If true, solution is in correction (show only correction, not both) | +| `show-corr` | bool | false | If true, show correction in "mixed" mode | +| `optional` | bool | false | Show optional marker before the label | +| `corr-given` | bool | false | Show correction-given marker (dumbbell icon) | +| `topic` | string | none | Topic metadata | +| `level` | string | none | Class/grade level metadata | +| `difficulty` | int/string | none | Difficulty level (key into `difficulty-scale`, e.g. 1-5) | +| `authors` | array | () | Array of author names | +| `..extra` | named | - | Additional metadata fields | + +### `exo-define` Function + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `exercise` | content | none | Exercise content (named parameter) | +| `solution` | content | none | Solution content | +| `correction` | content | none | Correction content (teacher version) | +| `id` | string/auto | auto | Unique exercise ID | +| `title` | content | none | Title shown after "Exercise 1" | +| `worked` | bool | false | Worked example: always show the solution/correction right after the statement | +| `competencies` | array | () | List of competency tags | +| `sol-in-corr` | bool | false | If true, solution is in correction (show only correction) | +| `show-corr` | bool | false | If true, show correction in "mixed" mode | +| `optional` | bool | false | Show optional marker before the label | +| `corr-given` | bool | false | Show correction-given marker (dumbbell icon) | +| `topic` | string | none | Topic metadata | +| `level` | string | none | Class/grade level metadata | +| `difficulty` | int/string | none | Difficulty level (key into `difficulty-scale`, e.g. 1-5) | +| `authors` | array | () | Array of author names | +| `..extra` | named | - | Additional metadata fields | + +### `exo-select` Function + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `topic` | string | none | Filter by exact topic | +| `level` | string | none | Filter by exact level | +| `difficulty` | int/string | none | Filter by difficulty level | +| `author` | string | none | Filter by exact author | +| `competency` | string | none | Filter by single competency | +| `topics` | array | none | Filter by any of these topics | +| `levels` | array | none | Filter by any of these levels | +| `difficulties` | array | none | Filter by any of these difficulty levels | +| `competencies` | array | none | Filter by any of these competencies | +| `where` | function | none | Custom filter function | +| `show-solutions` | bool/auto | auto | Override solution display | +| `renumber` | bool | true | Renumber exercises sequentially | +| `max` | int | none | Maximum exercises to show | + +### `exo-cite` Function + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `id` | string | required | ID of a displayed exercise | +| `pos-label` | label/none | none | Optional label on an `exo`, `exo-show`, `exo-select` or `exo-filter` display call; also accepted as the second positional argument | +| `topic` | string/none | none | Restrict matching displayed exercises to this topic | +| `occurrence` | positive int | 1 | Which matching display to cite, in document order | +| `show-page` | bool | true | Append the target page number | +| `show-part` | bool | false | Append the beautitled part, when available | +| `prefix` | content/string/auto/none | auto | Word before the number; auto uses the target exercise label; none hides it | +| `page-prefix` | content/string | "p. " | Text before the page number | +| `part-prefix` | content/string/auto/none | auto | Part label; auto uses the target's beautitled label; none hides it | + +### `exo-setup` Function + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `display` | string | "both" | "ex", "sol", "both" | +| `corr-display` | string | "solution" | "solution", "correction", "mixed" | +| `corr-loc` | string | "after" | "after", "pagebreak", "end-section", "end-chapter" | +| `sol-loc` | string/auto | auto | Same values as `corr-loc`, for solutions only (auto = follow `corr-loc`) | +| `solution-label` | string | "Solution" | Label for solutions | +| `correction-label` | string | "Correction" | Label for corrections | +| `exercise-label` | string | "Exercise" | Label for exercises | +| `counter-reset` | string | "section" | "section", "chapter", "global" | +| `number-prefix` | none/string/int/content/counter/function | none | "chapter" / "section" (chapter or chapter.section number, beautitled-aware), "heading" (heading number), a literal series number (restarts the numbering), a custom counter, or a function () => value | +| `number-prefix-depth` | int | 1 | Heading levels in a "heading" prefix (2 = "3.2.5") | +| `number-separator` | string | "." | Separator for chapter-prefixed numbers | +| `title-separator` | content | `[ -- ]` | Between "Exercise 1" and the title | +| `title-format` | auto/function | auto | Restyle titles: (title) => content | +| `title-in-solutions` | bool | false | Repeat the title on solution/correction boxes | +| `underline-gap` | length/auto | auto | underline only: title lower edge (normally baseline) → rule centre; overrides `header-rule-gap`; `none` keeps current value | +| `underline-below` | length/auto | auto | underline only: rule centre → body top; overrides `header-body-gap`; `none` keeps current value | +| `header-rule-gap` | length/auto | auto | underline style: space between the label and its rule | +| `header-body-gap` | length/auto | auto | Space between the header and the statement (full-width styles, `badge-position: "above"`) | +| `show-metadata` | bool | false | Display metadata | +| `show-id` | bool | false | Display exercise ID | +| `show-competencies` | bool | false | Display competency tags | +| `draft-mode` | bool | false | Show placeholders for empty content | +| `correction-placeholder` | content | `[_To be completed_]` | Placeholder for empty corrections | +| `solution-placeholder` | content | `[_To be completed_]` | Placeholder for empty solutions | +| `badge-style` | string/function | "box" | Visual style for badges, or a custom badge function | +| `badge-position` | string | "margin" | `"margin"` (badge in its own left column) or `"above"` (badge on a header line, statement full width below) | +| `badge-color` | color | black | Color for exercise badges | +| `badge-scale` | float | 1.0 | Multiplies the built-in badge paddings and the circle diameter | +| `badge-pad-x` | length/auto | auto | Horizontal padding inside the badge, overriding `badge-scale` | +| `badge-pad-y` | length/auto | auto | Vertical padding; the badge is `label-font-size + 2 * badge-pad-y` tall | +| `badge-radius` | length/auto | auto | Badge corner radius (auto = the style's own, scaled) | +| `margin-label-width` | length | 3.35cm | Side-label column of badge-style "margin" | +| `margin-label-gutter` | length | 0.55cm | Gap between that column and the statement | +| `margin-fold-below` | length/auto | auto | Below this measure the "margin" side label folds onto a header line (auto = 3 × `margin-label-width`; 0pt = never) | +| `solution-color` | color | green | Color for solution badges | +| `correction-color` | color | green | Color for correction badges | +| `exercise-above` | length | 0.8em | Space above exercise boxes | +| `exercise-below` | length | 0.8em | Space below exercise boxes | +| `solution-above` | length | 0.8em | Space above solution boxes | +| `solution-below` | length | 0.8em | Space below solution boxes | +| `correction-above` | length | 0.8em | Space above correction boxes | +| `correction-below` | length | 0.8em | Space below correction boxes | +| `advanced-symbol` | content/none | "*" | Symbol for advanced exercises | +| `optional-symbol` | content/none | star icon | Symbol for optional exercises | +| `corr-given-symbol` | content/none | dumbbell icon | Symbol when correction is handed out | +| `difficulty-display` | string | "color" | "color", "stars", "symbols", "none" | +| `difficulty-scale` | auto/dict | auto | auto = built-in 5-level scale, or dict key -> (color: .., symbol: ..) | +| `difficulty-position` | string | "below" | Stars/symbols "below" the badge or inline in the "badge" | +| `solution-style` | auto/string | auto | "inline" shows solutions as a short rule + content (no badge) | +| `inline-rule-length` | length | 3cm | Rule length for inline solutions | +| `inline-label` | auto/content/none | auto | Small margin label for inline solutions (auto = solution label) | +| `link-solutions` | bool | false | Clickable links between exercises and deferred corrections | +| `link-icon` | content/none | arrow icon | Link icon on the exercise | +| `backlink-icon` | content/none | arrow icon | Back-link icon on the correction | +| `link-style` | string | "icon" | "icon" (arrow) or "page" ("Solution p. 30" reference) | +| `page-ref-format` | auto/function | auto | Custom page reference: (label, page) => content | +| `page-ref-color` | auto/color | auto | Page reference color (auto = badge color) | +| `corr-columns` | int | 1 | Columns for the collected (end-section / end-chapter) corrections | +| `corr-columns-gutter` | length/ratio | 4% | Gutter between those columns | +| `corr-columns-rule` | stroke/none | none | Vertical separator between them, e.g. `0.5pt + gray` | +| `corr-columns-badge-position` | auto/string | auto | Badge position in those columns (auto = "above" from two columns on) | + +### Known limitation: `link-style: "page"` with a full-width badge style + +A document that uses `link-solutions: true` with `link-style: "page"` **and** declares one of the full-width badge styles (`margin`, `border-accent`, `underline`, `rounded-box`, `header-card`) anywhere fails to converge: Typst reports *"document did not converge within five attempts"* and, below it, *"query for elements labelled `` did not stabilize"*. The page numbers are resolved by looking up where each deferred correction landed, and that lookup does not settle in this combination. The rendered output is usually correct, but the page numbers are not guaranteed. + +This predates 0.6.4 (it reproduces on 0.6.3). Either use `link-style: "icon"`, or keep to the badge styles that draw a badge (`box`, `circled`, `filled-circle`, `rect`, `filled-rect`, `pill`, `tag`), which 0.6.4 does fix - those diverged too before this release. + +## Complete Example + +```typst +#import "@preview/exercise-bank:0.7.0": * + +// Setup +#exo-setup( + corr-loc: "end-section", + show-competencies: true, +) + += Algebra Exercises + +// Define exercises in a bank +#exo-define( + id: "alg-1", + exercise: [Solve $2x + 5 = 13$.], + topic: "equations", + level: "easy", + competencies: ("C1.1",), + solution: [$x = 4$], +) + +#exo-define( + id: "alg-2", + exercise: [Solve $x^2 = 9$.], + topic: "equations", + level: "medium", + competencies: ("C1.1", "C1.2"), + solution: [$x = 3$ or $x = -3$], +) + +#exo-define( + id: "alg-3", + exercise: [Solve $3x - 1 > 5$.], + topic: "inequalities", + level: "medium", + competencies: ("C1.3",), + solution: [$x > 2$], +) + +// Display exercises for this section +#exo-select(level: "easy") +#exo-select(level: "medium", max: 2) + +// Print solutions at end of section +#exo-print-solutions(title: "Answers") +``` + +## License + +MIT License - see LICENSE file for details. + +## Changelog + +### [0.7.0] - 2026-10-06 + +#### Added +- **Exercise citations.** `exo-cite(id)` references the actual displayed number and page, optionally the beautitled part, with forward/backward links, occurrence selection and display-call labels. Works across included chapter files and filtered selections spanning pages. Based on [Arthur Meyer’s proposal in issue #2](https://github.com/nathan-ed/typst-package-exercise-bank/issues/2), extended to use individual display anchors. +- **Underline spacing controls.** `underline-gap` (title lower edge, normally its baseline, to rule centre) and `underline-below` (rule centre to body top), with `none` leaving configuration unchanged and `auto` following the existing header settings; apply to exercises, solutions and corrections in every display mode. + +#### Fixed +- **Underline spacing depended on document paragraph spacing.** Internal blocks now use explicit spacing; the automatic default preserves the previous `par.spacing: 1.2em` appearance while body paragraphs keep the document's spacing. +- **Orphaned full-width headers.** On Typst 0.13+, `underline`, `border-accent`, `rounded-box` and `header-card` keep the header (and underline rule) with the first body line across page and column breaks, while long bodies remain breakable. + +### [0.6.5] - 2026-09-15 + +#### Added +- **Exercise titles.** `exo(title: ..)` (and `exo-define`) shows "Exercise 1 – Title": in the header line of the full-width styles, in bold next to the badge for the badge styles. `title-separator`, `title-format` and `title-in-solutions` configure it. +- **Worked examples.** `exo(worked: true)` always shows the solution/correction right after the statement, including with `display: "ex"` and a deferred `corr-loc`. Honoured by `exo-show`, `exo-select` and `exo-filter` for bank exercises. +- **Header spacing.** `header-rule-gap` (underline style) and `header-body-gap` (full-width styles and `badge-position: "above"`) set the space between the label, its rule and the statement. `auto` keeps the current look. +- **Series and section prefixes.** `number-prefix` accepts a literal value (`number-prefix: 3` → 3.1, 3.2, …), which restarts the numbering; `number-prefix-depth` builds a "heading" prefix from several heading levels (3.2.5). +- **`number-prefix: "chapter"` / `"section"`.** Chapter (3.5) or chapter.section (3.2.5) numbering that reads beautitled's own counters when present, so it stays right with `enable-parts`. +- **`exo-auto-chapter` handles sections.** It restarts the numbering at each section (automatically with `number-prefix: "section"`, or `sections: true`), and prints pending `"end-section"` corrections before each section and chapter heading. With beautitled `enable-parts`, chapters and sections are detected one heading level deeper. + +#### Fixed +- **`exo-auto-chapter` silently dropped pending `"end-section"` corrections at a chapter boundary.** They are now printed before the chapter heading. + +### [0.6.4] - 2026-09-01 + +#### Added +- **Two-column layouts.** `exo-page-columns` sets a whole document on page columns, `exo-columns` sets a single block, both with an optional vertical rule in the gutter and badges moved above the statements so they cost no column width. +- **`corr-columns`.** The corrections collected for the end of a section or chapter are printed from inside the flow, so nothing could wrap them in columns by hand. They now take `corr-columns`, `corr-columns-gutter`, `corr-columns-rule` and `corr-columns-badge-position` of their own. Without a rule they flow across pages like body text; with one they are laid out as a grid, which draws the bar at the true height of the content on every page the section spans. +- **`badge-style: "margin"` in narrow columns.** Its side label was a hard-coded 3.35cm column, which left a two-column statement about as wide as the label itself. It now folds onto a header line below `margin-fold-below` (`auto` = three times the label column) and whenever `badge-position` is `"above"`, so the two-column functions cover it like every other style. `margin-label-width` and `margin-label-gutter` make the unfolded geometry configurable. +- **Badge size.** `badge-scale` multiplies the paddings and corner radius every badge shape ships with; `badge-pad-x`, `badge-pad-y` and `badge-radius` replace them outright. The reserved label column follows the badge, so `badge-position: "margin"` stays aligned without touching `margin-position`. +- **`exo-columns(.., balance: true)`.** The block's columns are normally `columns` inside a block of estimated height, and that estimate is a cap: a body longer than a page was silently truncated. `balance: true` splits the body into its own pieces and lays them out as a grid instead, which breaks across pages and stops the rule at the content. Opt-in — see *Multi-Column Layouts* above for the one combination it does not survive. + +#### Fixed +- **With `badge-position: "above"`, a badge could be left alone at the bottom of a column or page while its statement started the next one.** The header line is now sticky (on Typst 0.13+, which the package does not require: on older compilers it degrades to a plain block). +- **`badge-style: "margin"` and `link-style: "page"` together never converged.** The page reference was wrapped into the statement with wrap-it, which measures the content — and the content carries the exercise's own link target, which `measure` cannot resolve against the real document. The full-width styles now put the reference on a line of its own above the statement, where it is laid out rather than measured. +- **The label column measured the badge it was about to draw.** Feeding a laid-out width back into the layout left any document that mixed badge styles *and* resolved page references short of converging. The column is now `auto` with a zero-height strut of the configured margin: the same constraint, stated declaratively, with nothing to converge on. +- **The page reference appeared only from the second layout pass on.** On the first pass the correction has not been laid out, so the lookup found nothing and no reference was emitted at all; adding one on the next pass moved the very page numbers being resolved. The space is now reserved either way, and only the text inside it waits for the target. + +### [0.6.3] - 2026-08-19 + +#### Fixed +- **With `qr-position: "tasks"`, the sentence before the `#tasks` call ran under the QR code in a narrow column.** The code is drawn as an overlay of zero flow height and the zone it occupies is published for taskize to lay its rows around, but text placed ahead of that call is not part of the grid, so nothing kept it clear of the overlay. In a wide single column the sentence fit on one line and stopped short of the code, which is why the collision only showed in two-column sheets. That leading text is now set at a reduced width for as long as the overlay lasts, and only the height the overlay still occupies below it is published as the wrap zone, so the tasks grid still uses whatever room is left. A leading sentence long enough to run past the code now flows back to the full width once clear of it, and a body with no `#tasks` call at all — which had no grid to hand the zone to, and so was never kept clear of the overlay — keeps out of it too. + +### [0.6.2] - 2026-08-10 + +#### Added +- **`badge-position: "above"`** — put the badge alone on a header line and let the statement run the full width underneath, instead of reserving a left badge column on every line. Independent of `badge-style`, so every badge look keeps working; useful in a narrow measure or with enumerated statements. + +#### Fixed +- **Page-counter convergence for `link-style: "page"` references** — the "Solution p. 30" reference sits in an `auto` grid column, so its width feeds back into the layout: a page number growing from 9 to 10 narrowed the text column, reflowed the statement and changed the number again ("page counter did not converge"). `make-page-ref` now reserves a width computed from an all-9s number of the same digit count, which no longer varies between layout passes. +- **Measurement stability for `link-style: "page"` references** — with `badge-position: "margin"` the reference was handed to wrap-it, which measures it to size the wrap zone; `measure` cannot resolve the link's label against the real document and falls back to the closest matching element, so the measurement changed from one layout pass to the next ("a measured element did not stabilize", "query for elements labelled … did not stabilize"). The wrap zone is now reserved with an empty box of the reference's exact size — nothing introspective to resolve — and the reference is placed out of flow on top, where it is never measured. + +### [0.6.1] - 2026-07-17 + +#### Added +- **`qr-position: "tasks"`** — overlay the QR at the top right with zero reserved flow height, for exercise bodies built from a [taskize](https://typst.app/universe/package/taskize/) `#tasks(...)` block; taskize narrows its own top rows to flow around the QR instead of the whole grid being pushed below it. Coordinates with taskize purely through a shared state key, with no import dependency in either direction — opt-in and inert on bodies without a `#tasks` call. + +#### Fixed +- Removed stray root-level `.claude/` directory from the source repo. + +### [0.6.0] - 2026-07-14 + +#### Added +- **Difficulty levels** — `difficulty:` on `exo`/`exo-define` with a configurable scale (up to 5 built-in levels); shown as badge colors (default), stars, or drawn symbols (seedling, pencil, target, mountain, star) via `difficulty-display` (stars/symbols sit below the badge by default, `difficulty-position: "badge"` for inline); new `difficulty`/`difficulties` filters on `exo-select`, `exo-filter`, and `exo-count` +- **Clickable exercise ↔ correction links** — `link-solutions: true` adds an arrow icon on the exercise jumping to its deferred correction and a back-link on the correction (customizable `link-icon`/`backlink-icon`); with `link-style: "page"` the exercise instead shows a textbook-style clickable "Solution p. 30" reference at the top right of the statement +- **Split solution/correction placement** — new `sol-loc` setting so solutions and corrections can go to different locations (e.g. solution right after the statement, correction at the end of the chapter) +- **Inline solution style** — `solution-style: "inline"` renders solutions as a short epigraph-like rule + content directly under the statement, with a small margin label (rule length via `inline-rule-length`, label via `inline-label`) +- **Automatic end-of-chapter corrections** — `#show: exo-auto-chapter` prints pending corrections before each new level-1 heading and at the end of the document +- **Chapter-prefixed numbering** — `number-prefix: "heading"` displays exercise numbers as "3.5" using the current level-1 heading number; also accepts a custom counter or function for heading packages with their own counters, e.g. beautitled's `chapter-counter` (`number-separator` configurable) +- **`rect` and `filled-rect` badge styles** — compact number-only rectangles, a minimal alternative to the circle styles +- **Custom badge functions** — `badge-style` accepts a function `(label, number, font-size, color, is-solution) => content` + +#### Fixed +- **Markers on all badge styles** — the optional/corr-given/difficulty markers now show on every badge style (`circled`, `filled-circle`, `pill`, `tag`, `rect`, `filled-rect`) and on full-width styles; previously they only appeared with the default `box` style + +### [0.5.3] - 2026-07-13 + +#### Added +- **QR codes** — attach a QR code to any exercise, solution, or correction box via `qr`, `qr-sol`, and `qr-corr` parameters (accepts a URL string or arbitrary content). Generated with [tiaoma](https://typst.app/universe/package/tiaoma/). +- **QR placement per badge style** — in margin/badge styles the code sits below the badge in the label column; in full-width styles the exercise content wraps around the code (via [wrap-it](https://typst.app/universe/package/wrap-it/)). +- **Global QR configuration in `exo-setup`** — `show-qr`, `qr-size`, `qr-min-size`, `qr-color`, `qr-caption`, and `qr-position` to control size, color, caption, placement mode, and a master on/off toggle for print versions. + +### [0.5.2] - 2026-07-02 + +#### Added +- **Optional exercise marker** — `optional: true` on `exo`/`exo-define`/`exo-show` shows a built-in star icon before the label; customize with `exo-setup(optional-symbol: ...)` or set to `none` to disable +- **Correction-given marker** — `corr-given: true` shows a dumbbell icon indicating the printed correction will be distributed to students; customize with `exo-setup(corr-given-symbol: ...)` +- **`margin` badge style** — side-label layout with a fixed-width left column and a horizontal rule; the 10th badge style +- **Per-display overrides in `exo-show`** — `optional`, `optional-symbol`, `corr-given`, `corr-given-symbol` can override bank metadata for a specific display call + +### [0.5.1] - 2026-06-19 + +#### Fixed +- **Documentation link** — the "Documentation (PDF)" link now resolves correctly. It previously pointed to a commit that did not contain `docs/manual.pdf` (the file had been excluded by a `*.pdf` gitignore rule), returning a 404 on GitHub and Typst Universe. + +### [0.5.0] - 2026-05-22 + +#### Removed +- **g-exam integration** — the `exam-question`, `exam-question-many`, and `exam-select` wrapper functions have been removed, along with the unconditional `@preview/g-exam` import. The `display-mode: "exam"` flag (which shows points in the badge via `exo-show`) is retained and requires no external dependency. + +### [0.4.0] - 2026-02-11 + +#### Changed (Breaking) +- **New display control system**: Replaced `solution-mode`, `fallback-to-correction`, and `append-solution-to-correction` with three clearer parameters: + - `display`: Controls what to display - `"ex"` (exercises only), `"sol"` (solutions only), `"both"` (default) + - `corr-display`: Controls which content type to show - `"solution"` (default), `"correction"`, `"mixed"` + - `corr-loc`: Controls where solutions appear - `"after"` (default), `"pagebreak"`, `"end-section"`, `"end-chapter"` + +- **New exercise-level flags**: + - `sol-in-corr`: If true, correction already contains solution (show only correction, not both) + - `show-corr`: If true, show correction in "mixed" mode for this exercise + +#### Removed +- `solution-mode` parameter (replaced by `show` and `corr-loc`) +- `fallback-to-correction` parameter (behavior controlled by `corr-display`) +- `append-solution-to-correction` parameter (use `corr-display: "mixed"` instead) +- `solution-in-correction-style` parameter (no longer needed) + +#### Migration Guide +| Old Parameter | New Equivalent | +|--------------|----------------| +| `solution-mode: "inline"` | `display: "both", corr-loc: "after"` (default) | +| `solution-mode: "none"` | `display: "ex"` | +| `solution-mode: "only"` | `display: "sol"` | +| `solution-mode: "end-section"` | `corr-loc: "end-section"` | +| `solution-mode: "end-chapter"` | `corr-loc: "end-chapter"` | +| `fallback-to-correction: true` | `corr-display: "correction"` | +| `append-solution-to-correction: true` | `corr-display: "mixed"` with `show-corr: true` on exercises | + +### [0.3.0] - 2026-01-27 + +#### Added +- **9 visual badge styles**: Configure with `exo-setup(badge-style: "...")` +- **Badge color customization**: `exo-setup(badge-color: rgb("#2563eb"))` +- **Separate solution/correction colors** +- **Advanced exercise markers**: `advanced-symbol` parameter +- **Spacing control**: Independent spacing for exercises, solutions, and corrections + +### [0.2.0] - 2026-01-15 + +#### Changed (Breaking) +- `exo` function: Content parameter changed from positional to named parameter `exercise:` +- `exo-define` function: Content parameter changed from positional to named parameter `exercise:` +- Author metadata: Changed from single `author` to `authors` array + +### [0.1.0] - 2026-01-13 + +- Initial release diff --git a/packages/preview/exercise-bank/0.7.0/gallery/badge-position.svg b/packages/preview/exercise-bank/0.7.0/gallery/badge-position.svg new file mode 100644 index 0000000000..247074e533 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/badge-position.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/badge-position.typ b/packages/preview/exercise-bank/0.7.0/gallery/badge-position.typ new file mode 100644 index 0000000000..6c95c63b11 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/badge-position.typ @@ -0,0 +1,31 @@ +// Badge position - exercise-bank +// badge-position: "above" frees the width the left badge column costs on every +// line, which matters most in a narrow measure and with enumerations + +#import "@preview/exercise-bank:0.7.0": * + +#set page(width: 9cm, height: auto, margin: 1cm) +#set text(font: "New Computer Modern", size: 10pt) +#set par(justify: true) + +#let statement = [ + Lors d'une élection, il y avait 41751 inscrits, 22159 votants et M. X a + obtenu 12826 voix. + + Donner le résultat de M. X en pourcentage des votants, puis en pourcentage + des inscrits. + + Donner le pourcentage d'abstention. +] + +#exo-setup(badge-style: "filled-circle", badge-color: rgb("#1a4f8a")) + +*badge-position: "margin"* (défaut) + +#exo(exercise: statement) + +#v(0.5em) + +*badge-position: "above"* + +#exo-setup(badge-position: "above") + +#exo(exercise: statement) diff --git a/packages/preview/exercise-bank/0.7.0/gallery/badge-size.svg b/packages/preview/exercise-bank/0.7.0/gallery/badge-size.svg new file mode 100644 index 0000000000..26f813a149 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/badge-size.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/badge-size.typ b/packages/preview/exercise-bank/0.7.0/gallery/badge-size.typ new file mode 100644 index 0000000000..1b3fcfeb6e --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/badge-size.typ @@ -0,0 +1,26 @@ +// Badge size - exercise-bank +// badge-scale multiplies the paddings of any badge shape; badge-pad-x, +// badge-pad-y and badge-radius replace them outright + +#import "@preview/exercise-bank:0.7.0": * + +#set page(width: 11cm, height: auto, margin: 1cm) +#set text(font: "New Computer Modern", size: 10pt) + +#exo-setup( + exercise-label: "Exercice", + badge-position: "above", + badge-color: rgb("#1a5276"), + label-font-size: 10pt, +) + +#for style in ("filled-rect", "pill") { + for scale in (0.5, 1.0, 1.6) { + exo-setup(badge-style: style, badge-scale: scale) + exo(exercise: [`badge-style: "#style"`, `badge-scale: #scale`]) + } +} + +#exo-setup(badge-style: "pill", badge-scale: 1.0, + badge-pad-x: 6pt, badge-pad-y: 2pt, badge-radius: 7pt) +#exo(exercise: [`badge-pad-x: 6pt`, `badge-pad-y: 2pt`, `badge-radius: 7pt`]) diff --git a/packages/preview/exercise-bank/0.7.0/gallery/bank.svg b/packages/preview/exercise-bank/0.7.0/gallery/bank.svg new file mode 100644 index 0000000000..fd838cd875 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/bank.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/bank.typ b/packages/preview/exercise-bank/0.7.0/gallery/bank.typ new file mode 100644 index 0000000000..67969179fb --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/bank.typ @@ -0,0 +1,33 @@ +#import "@preview/exercise-bank:0.7.0": exo-define, exo-show, exo-show-many, exo-setup + +#set page(width: 14cm, height: auto, margin: 1cm) + += Exercise Bank + +// Define exercises (not displayed yet) +#exo-define( + id: "quad-1", + exercise: [Solve $x^2 - 5x + 6 = 0$.], + topic: "quadratics", + solution: [$x = 2$ or $x = 3$], +) + +#exo-define( + id: "quad-2", + exercise: [State the quadratic formula.], + topic: "quadratics", + solution: [$x = frac(-b plus.minus sqrt(b^2 - 4 a c), 2 a)$], +) + +#exo-define( + id: "geom-1", + exercise: [Find the area of a circle with radius 5.], + topic: "geometry", + solution: [$A = pi r^2 = 25pi$], +) + +== Selected Exercises + +// Show specific exercises by ID +#exo-show("quad-1") +#exo-show("geom-1") diff --git a/packages/preview/exercise-bank/0.7.0/gallery/basic.svg b/packages/preview/exercise-bank/0.7.0/gallery/basic.svg new file mode 100644 index 0000000000..7ae0e9a6fb --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/basic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/basic.typ b/packages/preview/exercise-bank/0.7.0/gallery/basic.typ new file mode 100644 index 0000000000..5e4160daf3 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/basic.typ @@ -0,0 +1,17 @@ +#import "@preview/exercise-bank:0.7.0": exo + +#set page(width: 14cm, height: auto, margin: 1cm) + += Basic Exercises + +#exo(exercise: [ + Solve the equation $2x + 5 = 13$. +]) + +#exo(exercise: [ + Calculate the area of a rectangle with length 8 cm and width 5 cm. +]) + +#exo(exercise: [ + Factor the expression $x^2 - 9$. +]) diff --git a/packages/preview/exercise-bank/0.7.0/gallery/competencies.svg b/packages/preview/exercise-bank/0.7.0/gallery/competencies.svg new file mode 100644 index 0000000000..2805ebbb07 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/competencies.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/competencies.typ b/packages/preview/exercise-bank/0.7.0/gallery/competencies.typ new file mode 100644 index 0000000000..d624e20b3a --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/competencies.typ @@ -0,0 +1,26 @@ +#import "@preview/exercise-bank:0.7.0": exo-define, exo-show, exo-setup + +#set page(width: 14cm, height: auto, margin: 1cm) + +#exo-setup( + show-competencies: true, +) + += Exercises with Competencies + +#exo-define( + id: "comp-1", + exercise: [Solve $2x + 5 = 13$ and explain your method.], + competencies: ("C1.1", "C2.3"), + solution: [$x = 4$], +) + +#exo-define( + id: "comp-2", + exercise: [Determine the nature of the roots of $x^2 - 3x + 2 = 0$.], + competencies: ("C1.2", "C3.1", "C4.1"), + solution: [The discriminant is $b^2 - 4 a c = 1 > 0$, so there are two real solutions.], +) + +#exo-show("comp-1") +#exo-show("comp-2") diff --git a/packages/preview/exercise-bank/0.7.0/gallery/corrections-only.svg b/packages/preview/exercise-bank/0.7.0/gallery/corrections-only.svg new file mode 100644 index 0000000000..759f234d55 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/corrections-only.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/corrections-only.typ b/packages/preview/exercise-bank/0.7.0/gallery/corrections-only.typ new file mode 100644 index 0000000000..63bc432ae8 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/corrections-only.typ @@ -0,0 +1,49 @@ +#import "@preview/exercise-bank:0.7.0": exo, exo-setup + +#set page(width: 14cm, height: auto, margin: 1cm) + +#exo-setup( + display: "sol", // Only show solutions/corrections (hide exercises) + corr-display: "correction", // Show corrections +) + += Teacher's Answer Key + +This document shows only corrections/solutions (exercises are hidden). +Perfect for creating teacher answer keys. + +#exo( + exercise: [Calculate $3 + 4 times 2$. (This exercise text is hidden)], + correction: [ + $3 + 4 times 2 = 3 + 8 = 11$ + ], +) + +#exo( + exercise: [Solve $x^2 = 9$. (This exercise text is hidden)], + correction: [ + *Correction:*\ + $x^2 = 9$\ + $x = plus.minus 3$\ + \ + Common mistake: Students forget the negative root. + ], +) + +#exo( + exercise: [Factor $x^2 - 4$. (Hidden)], + correction: [ + $x^2 - 4 = (x + 2)(x - 2)$ + ], +) + +#exo( + exercise: [Simplify $2(x + 3) - 4x$. (Hidden)], + correction: [ + $2(x + 3) - 4x$\ + $= 2x + 6 - 4x$\ + $= -2x + 6$\ + \ + Teaching tip: Emphasize distribution before combining like terms. + ], +) diff --git a/packages/preview/exercise-bank/0.7.0/gallery/corrections.svg b/packages/preview/exercise-bank/0.7.0/gallery/corrections.svg new file mode 100644 index 0000000000..cfaf061166 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/corrections.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/corrections.typ b/packages/preview/exercise-bank/0.7.0/gallery/corrections.typ new file mode 100644 index 0000000000..17c9402964 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/corrections.typ @@ -0,0 +1,50 @@ +#import "@preview/exercise-bank:0.7.0": exo, exo-setup + +#set page(width: 14cm, height: auto, margin: 1cm) + +#exo-setup( + corr-display: "correction", // Show corrections instead of solutions +) + += Correction Display Demo + +== Exercise with correction only +#exo( + exercise: [ + Calculate $3 + 4 times 2$. + ], + correction: [ + $3 + 4 times 2 = 3 + 8 = 11$ + ], +) + +== Exercise with detailed correction +#exo( + exercise: [ + Solve $x^2 = 9$. + ], + correction: [ + *Teacher's correction:*\ + This is a basic quadratic equation.\ + $x^2 = 9$\ + $x = plus.minus 3$\ + \ + Students often forget the negative root. Make sure to emphasize both solutions. + ], +) + +== Exercise with both (correction shown) +#exo( + exercise: [ + Factor $x^2 - 4$. + ], + solution: [ + $x^2 - 4 = (x + 2)(x - 2)$ + ], + correction: [ + *Detailed correction:*\ + This is a difference of squares: $a^2 - b^2 = (a+b)(a-b)$\ + With $a = x$ and $b = 2$:\ + $x^2 - 4 = (x + 2)(x - 2)$ + ], +) diff --git a/packages/preview/exercise-bank/0.7.0/gallery/difficulty.svg b/packages/preview/exercise-bank/0.7.0/gallery/difficulty.svg new file mode 100644 index 0000000000..0ee0cec410 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/difficulty.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/difficulty.typ b/packages/preview/exercise-bank/0.7.0/gallery/difficulty.typ new file mode 100644 index 0000000000..d96f0fd2d1 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/difficulty.typ @@ -0,0 +1,54 @@ +// Difficulty encoding - exercise-bank +// Up to 5 levels, shown as badge colors, stars, or symbols + +#import "@preview/exercise-bank:0.7.0": * + +#set page(width: 14cm, height: auto, margin: 1cm) +#set text(font: "New Computer Modern", size: 11pt) + += Difficulty Levels + +== Colors (default) + +The exercise badge takes the color of the difficulty level: + +#exo(exercise: [Level 1 -- introductory. Compute $2 + 2$.], difficulty: 1) +#exo(exercise: [Level 2 -- standard. Solve $3x = 12$.], difficulty: 2) +#exo(exercise: [Level 3 -- exam-type. Solve $x^2 - 4x + 3 = 0$.], difficulty: 3) +#exo(exercise: [Level 4 -- advanced. Show that $sqrt(2)$ is irrational.], difficulty: 4) +#exo(exercise: [Level 5 -- expert. Prove there are infinitely many primes.], difficulty: 5) + +== Stars + +#exo-setup(difficulty-display: "stars") + +#exo(exercise: [One star. Compute $5 dot 6$.], difficulty: 1) +#exo(exercise: [Three stars. Factor $x^2 - 9$.], difficulty: 3) +#exo(exercise: [Five stars. A real challenge.], difficulty: 5) + +== Symbols + +Seedling, pencil, target, mountain, star: + +#exo-setup(difficulty-display: "symbols") + +#exo(exercise: [Seedling -- introductory.], difficulty: 1) +#exo(exercise: [Pencil -- standard.], difficulty: 2) +#exo(exercise: [Target -- exam-type.], difficulty: 3) +#exo(exercise: [Mountain -- advanced.], difficulty: 4) +#exo(exercise: [Star -- expert.], difficulty: 5) + +== Custom scale + +Define your own levels, colors, and symbols: + +#exo-setup( + difficulty-display: "color", + difficulty-scale: ( + "easy": (color: rgb("#00897b")), + "hard": (color: rgb("#e65100")), + ), +) + +#exo(exercise: [An "easy" one.], difficulty: "easy") +#exo(exercise: [A "hard" one.], difficulty: "hard") diff --git a/packages/preview/exercise-bank/0.7.0/gallery/draft-mode-1.svg b/packages/preview/exercise-bank/0.7.0/gallery/draft-mode-1.svg new file mode 100644 index 0000000000..2995f94726 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/draft-mode-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/draft-mode-2.svg b/packages/preview/exercise-bank/0.7.0/gallery/draft-mode-2.svg new file mode 100644 index 0000000000..fc9030e2c9 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/draft-mode-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/draft-mode-3.svg b/packages/preview/exercise-bank/0.7.0/gallery/draft-mode-3.svg new file mode 100644 index 0000000000..ccd78cedad --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/draft-mode-3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/draft-mode.typ b/packages/preview/exercise-bank/0.7.0/gallery/draft-mode.typ new file mode 100644 index 0000000000..66a52fedc1 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/draft-mode.typ @@ -0,0 +1,80 @@ +#import "@preview/exercise-bank:0.7.0": * + +#set page(width: 16cm, height: auto, margin: 1cm) + += Draft Mode Demo + +== Part 1: Draft Mode OFF (Student Version) + +Empty corrections/solutions maintain counter but show no placeholder. + +#exo-setup( + draft-mode: false, // Student version - no placeholders visible +) + +#exo( + exercise: [Solve $x + 5 = 12$], + solution: [], // Empty - shows space but no placeholder +) + +#exo( + exercise: [Calculate $3 times 4$], + solution: [$3 times 4 = 12$], // Normal solution +) + +#exo( + exercise: [Simplify $2x + 3x$], + solution: [], // Empty - counter continues +) + +#pagebreak() + +== Part 2: Draft Mode ON (Teacher Version) + +Empty corrections/solutions show placeholders for completion. + +#exo-setup( + draft-mode: true, // Teacher draft - shows placeholders + correction-placeholder: [_[To be completed]_], + solution-placeholder: [_[Answer to be written]_], +) + +#exo( + exercise: [Solve $x + 5 = 12$], + solution: [], // Shows placeholder: "To be completed" +) + +#exo( + exercise: [Calculate $3 times 4$], + solution: [$3 times 4 = 12$], // Normal solution +) + +#exo( + exercise: [Simplify $2x + 3x$], + solution: [], // Shows placeholder +) + +#pagebreak() + +== Part 3: With Correction Mode + +#exo-setup( + draft-mode: true, + corr-display: "correction", // Show corrections + correction-placeholder: [_[Explanation to be written]_], +) + +#exo( + exercise: [Factor $x^2 - 4$], + correction: [], // Empty correction - shows placeholder +) + +#exo( + exercise: [What is $5 + 7$?], + correction: [Add the two numbers to get 12.], // Real correction +) + +#exo( + exercise: [Expand $(x+3)^2$], + correction: [], // Empty - shows placeholder +) diff --git a/packages/preview/exercise-bank/0.7.0/gallery/end-chapter.svg b/packages/preview/exercise-bank/0.7.0/gallery/end-chapter.svg new file mode 100644 index 0000000000..baa94ec605 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/end-chapter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/end-chapter.typ b/packages/preview/exercise-bank/0.7.0/gallery/end-chapter.typ new file mode 100644 index 0000000000..5ad8d72774 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/end-chapter.typ @@ -0,0 +1,38 @@ +// End-of-chapter corrections - exercise-bank +// exo-auto-chapter prints the pending corrections before each new level-1 +// heading and at the end of the document + +#import "@preview/exercise-bank:0.7.0": * + +#set page(width: 14cm, height: auto, margin: 1cm) +#set text(font: "New Computer Modern", size: 11pt) + +#exo-setup( + corr-loc: "end-chapter", + counter-reset: "chapter", + exercise-label: "Exercice", + solution-label: "Corrigé", +) + +#show: exo-auto-chapter + += Chapitre 1 + +#exo( + exercise: [Résoudre $2x + 3 = 11$.], + solution: [$2x = 8$, donc $x = 4$.], +) + +#exo( + exercise: [Factoriser $x^2 - 16$.], + solution: [$x^2 - 16 = (x-4)(x+4)$.], +) + += Chapitre 2 + +Les corrigés du chapitre 1 apparaissent ci-dessus, juste avant ce titre. + +#exo( + exercise: [Résoudre $x^2 = 25$.], + solution: [$x = 5$ ou $x = -5$.], +) diff --git a/packages/preview/exercise-bank/0.7.0/gallery/end-section.svg b/packages/preview/exercise-bank/0.7.0/gallery/end-section.svg new file mode 100644 index 0000000000..ccd68982f9 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/end-section.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/end-section.typ b/packages/preview/exercise-bank/0.7.0/gallery/end-section.typ new file mode 100644 index 0000000000..b336a2a352 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/end-section.typ @@ -0,0 +1,27 @@ +#import "@preview/exercise-bank:0.7.0": exo, exo-setup, exo-print-solutions + +#set page(width: 14cm, height: auto, margin: 1cm) + +#exo-setup(corr-loc: "end-section") + += Algebra Section + +#exo( + exercise: [Solve $2x + 5 = 13$.], + solution: [$x = 4$], +) + +#exo( + exercise: [Solve $x^2 = 9$.], + solution: [$x = plus.minus 3$], +) + +#exo( + exercise: [Solve $3x - 1 > 5$.], + solution: [$x > 2$], +) + +#v(1em) +#line(length: 100%, stroke: 0.5pt) + +#exo-print-solutions(title: "Answers") diff --git a/packages/preview/exercise-bank/0.7.0/gallery/filtering.svg b/packages/preview/exercise-bank/0.7.0/gallery/filtering.svg new file mode 100644 index 0000000000..c50d90eddd --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/filtering.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/filtering.typ b/packages/preview/exercise-bank/0.7.0/gallery/filtering.typ new file mode 100644 index 0000000000..ec68eefa10 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/filtering.typ @@ -0,0 +1,47 @@ +#import "@preview/exercise-bank:0.7.0": exo-define, exo-select, exo-setup + +#set page(width: 14cm, height: auto, margin: 1cm) + +#exo-setup(display: "ex") // Only show exercises (no solutions) + +// Define exercises with metadata +#exo-define( + id: "alg-1", + exercise: [Solve $x + 5 = 12$.], + topic: "algebra", + level: "easy", +) + +#exo-define( + id: "alg-2", + exercise: [Solve $2x^2 - 8 = 0$.], + topic: "algebra", + level: "medium", +) + +#exo-define( + id: "geom-1", + exercise: [Calculate the perimeter of a square with side 4.], + topic: "geometry", + level: "easy", +) + +#exo-define( + id: "geom-2", + exercise: [Find the area of a triangle with base 6 and height 8.], + topic: "geometry", + level: "medium", +) + += Filtering by Topic + +== Algebra Exercises +#exo-select(topic: "algebra") + +== Geometry Exercises +#exo-select(topic: "geometry") + += Filtering by Level + +== Easy Exercises +#exo-select(level: "easy") diff --git a/packages/preview/exercise-bank/0.7.0/gallery/inline-solutions.svg b/packages/preview/exercise-bank/0.7.0/gallery/inline-solutions.svg new file mode 100644 index 0000000000..f7c9ba5a4c --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/inline-solutions.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/inline-solutions.typ b/packages/preview/exercise-bank/0.7.0/gallery/inline-solutions.typ new file mode 100644 index 0000000000..bced8a2fcd --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/inline-solutions.typ @@ -0,0 +1,36 @@ +// Inline solutions + deferred corrections - exercise-bank +// The short answer stays with the statement (epigraph-like rule), while the +// full correction goes to the end of the chapter + +#import "@preview/exercise-bank:0.7.0": * + +#set page(width: 14cm, height: auto, margin: 1cm) +#set text(font: "New Computer Modern", size: 11pt) + +#exo-setup( + corr-display: "correction", // show both the correction and the solution + sol-loc: "after", // ... the solution right below the statement + corr-loc: "end-chapter", // ... the correction at the end of the chapter + solution-style: "inline", // epigraph-like: short rule, no badge + exercise-label: "Exercice", + correction-label: "Corrigé", +) + +#show: exo-auto-chapter + += Exercices + +#exo( + exercise: [Résoudre $x^2 - 5x + 6 = 0$.], + solution: [$x in {2, 3}$], + correction: [On cherche deux nombres de somme $5$ et de produit $6$ : ce sont + $2$ et $3$. Donc $x^2 - 5x + 6 = (x - 2)(x - 3)$ et les solutions sont + $x = 2$ et $x = 3$.], +) + +#exo( + exercise: [Déterminer la dérivée de $f(x) = x^3 - 3x$.], + solution: [$f'(x) = 3x^2 - 3$], + correction: [On dérive terme à terme : $(x^3)' = 3x^2$ et $(3x)' = 3$, d'où + $f'(x) = 3x^2 - 3 = 3(x^2 - 1) = 3(x-1)(x+1)$.], +) diff --git a/packages/preview/exercise-bank/0.7.0/gallery/linked-solutions.svg b/packages/preview/exercise-bank/0.7.0/gallery/linked-solutions.svg new file mode 100644 index 0000000000..fb86b207eb --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/linked-solutions.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/linked-solutions.typ b/packages/preview/exercise-bank/0.7.0/gallery/linked-solutions.typ new file mode 100644 index 0000000000..81d34376ca --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/linked-solutions.typ @@ -0,0 +1,49 @@ +// Clickable exercise <-> correction links - exercise-bank +// When corrections are deferred (end-chapter, end-section, pagebreak), an +// arrow icon on the exercise jumps to its correction, and the correction +// carries a back-link to the statement + +#import "@preview/exercise-bank:0.7.0": * + +#set page(width: 14cm, height: auto, margin: 1cm) +#set text(font: "New Computer Modern", size: 11pt) + +#exo-setup( + corr-loc: "end-chapter", + link-solutions: true, + exercise-label: "Exercice", + solution-label: "Corrigé", +) + +#show: exo-auto-chapter + += Exercices + +#exo( + exercise: [L'icône flèche à côté du badge est cliquable : elle mène au corrigé + en fin de chapitre. Résoudre $x^2 - 5x + 6 = 0$.], + solution: [On factorise : $(x-2)(x-3) = 0$, donc $x in {2, 3}$. + L'icône retour ramène à l'énoncé.], +) + +#exo( + exercise: [Calculer $integral_0^1 x^2 dif x$.], + solution: [$integral_0^1 x^2 dif x = [x^3 / 3]_0^1 = 1/3$.], +) + +== Style manuel scolaire + +Avec `link-style: "page"`, l'icône est remplacée par une référence +« Corrigé p. N » cliquable en haut à droite de l'énoncé : + +#exo-setup( + link-style: "page", + badge-style: "filled-rect", + badge-color: rgb("#1a4d8f"), +) + +#exo( + exercise: [Esquisser le graphe d'une primitive de la fonction $f$.], + solution: [Toute primitive $F$ vérifie $F' = f$ : on lit la pente de $F$ + sur le graphe de $f$.], +) diff --git a/packages/preview/exercise-bank/0.7.0/gallery/markers.svg b/packages/preview/exercise-bank/0.7.0/gallery/markers.svg new file mode 100644 index 0000000000..818e452b64 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/markers.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/markers.typ b/packages/preview/exercise-bank/0.7.0/gallery/markers.typ new file mode 100644 index 0000000000..2bf66703bb --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/markers.typ @@ -0,0 +1,53 @@ +#import "@preview/exercise-bank:0.7.0": * + +#set page(width: 14cm, height: auto, margin: 1cm) +#set text(font: "New Computer Modern", size: 11pt) + += Exercise Markers + +== Optional exercises + +#exo( + exercise: [Skip this if you're short on time. Solve $x^2 - x - 6 = 0$.], + solution: [$x = 3$ or $x = -2$], + optional: true, +) + +#exo( + exercise: [Solve $3x - 9 = 0$.], + solution: [$x = 3$], +) + +Customize the optional symbol: + +#exo-setup(optional-symbol: [⭐]) +#exo( + exercise: [Optional with star marker. Solve $2x + 4 = 0$.], + solution: [$x = -2$], + optional: true, +) + +#exo-setup(optional-symbol: optional-star-icon()) + +== Correction-given exercises + +Mark exercises whose printed correction will be distributed to students: + +#exo( + exercise: [Factor $x^2 - 9$.], + solution: [$(x-3)(x+3)$], + corr-given: true, +) + +#exo( + exercise: [Solve the system $cases(x + y = 5, x - y = 1)$.], + solution: [$x = 3$, $y = 2$], +) + +== Combined markers + +#exo( + exercise: [*Challenging and optional.* Prove that $sqrt(2)$ is irrational.], + advanced: true, + optional: true, +) diff --git a/packages/preview/exercise-bank/0.7.0/gallery/mixed-display.svg b/packages/preview/exercise-bank/0.7.0/gallery/mixed-display.svg new file mode 100644 index 0000000000..4edafe2334 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/mixed-display.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/mixed-display.typ b/packages/preview/exercise-bank/0.7.0/gallery/mixed-display.typ new file mode 100644 index 0000000000..2dd34828e0 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/mixed-display.typ @@ -0,0 +1,56 @@ +#import "@preview/exercise-bank:0.7.0": exo, exo-setup + +#set page(width: 14cm, height: auto, margin: 1cm) + +#exo-setup( + corr-display: "mixed", // Default to solution, but show correction for exercises with show-corr: true +) + += Mixed Display Mode + +In "mixed" mode, exercises default to showing their solution. +When an exercise has `show-corr: true`, it shows the correction instead. + +== Default: Solution shown +#exo( + exercise: [ + Solve the equation $x^2 = 9$. + ], + solution: [ + $x = plus.minus 3$ + ], + correction: [ + This is a basic quadratic. Don't forget the negative root! + The solutions are $x = 3$ and $x = -3$. + ], +) + +== With show-corr: Correction shown +#exo( + exercise: [ + Calculate the area of a circle with radius 5. + ], + solution: [ + $A = 25pi approx 78.54$ + ], + correction: [ + Use the formula $A = pi r^2$. + + Substitute $r = 5$: + $A = pi times 5^2 = 25pi approx 78.54$ + ], + show-corr: true, // This exercise shows correction instead of solution +) + +== Another default: Solution shown +#exo( + exercise: [ + Factor $x^2 - 4$. + ], + solution: [ + $(x + 2)(x - 2)$ + ], + correction: [ + This is a difference of squares pattern. + ], +) diff --git a/packages/preview/exercise-bank/0.7.0/gallery/pagebreak-1.svg b/packages/preview/exercise-bank/0.7.0/gallery/pagebreak-1.svg new file mode 100644 index 0000000000..d36549656f --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/pagebreak-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/pagebreak-2.svg b/packages/preview/exercise-bank/0.7.0/gallery/pagebreak-2.svg new file mode 100644 index 0000000000..7d606319d2 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/pagebreak-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/pagebreak-3.svg b/packages/preview/exercise-bank/0.7.0/gallery/pagebreak-3.svg new file mode 100644 index 0000000000..d55d4a0768 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/pagebreak-3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/pagebreak.typ b/packages/preview/exercise-bank/0.7.0/gallery/pagebreak.typ new file mode 100644 index 0000000000..7859774fd4 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/pagebreak.typ @@ -0,0 +1,29 @@ +#import "@preview/exercise-bank:0.7.0": exo, exo-setup + +#set page(width: 14cm, height: 8cm, margin: 1cm) + +#exo-setup(corr-loc: "pagebreak") // Solutions on separate page + += Page Break Demo + +Each solution appears on a new page after its exercise. + +#exo( + exercise: [ + Solve the equation $2x + 5 = 13$. + ], + solution: [ + $2x + 5 = 13$\ + $2x = 8$\ + $x = 4$ + ], +) + +#exo( + exercise: [ + Factor $x^2 - 9$. + ], + solution: [ + $x^2 - 9 = (x + 3)(x - 3)$ + ], +) diff --git a/packages/preview/exercise-bank/0.7.0/gallery/qr-codes.svg b/packages/preview/exercise-bank/0.7.0/gallery/qr-codes.svg new file mode 100644 index 0000000000..53ce455391 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/qr-codes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/qr-codes.typ b/packages/preview/exercise-bank/0.7.0/gallery/qr-codes.typ new file mode 100644 index 0000000000..2c3e45e9fb --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/qr-codes.typ @@ -0,0 +1,42 @@ +// QR Codes Gallery - exercise-bank +// Per-exercise QR codes, placed automatically for each badge style + +#import "@preview/exercise-bank:0.7.0": * + +#set page(width: 14cm, height: auto, margin: 1cm) +#set text(font: "New Computer Modern", size: 11pt) + += QR Codes + +== Below the badge (badge styles) + +#exo( + exercise: [ + Solve the equation $2x + 5 = 13$ and check your answer. + The QR code links to a video correction. + ], + solution: [$x = 4$], + qr: "https://example.com/corrections/exo-1", +) + +#exo-setup(badge-style: "pill", badge-color: rgb("#059669"), qr-caption: [Corrigé]) +#exo( + exercise: [ + Factor $x^2 - 5x + 6$ completely. Scan the code below the pill + for a step-by-step correction. + ], + qr: "https://example.com/corrections/exo-2", +) + +== Wrapped by the content (full-width styles) + +#exo-setup(badge-style: "header-card", badge-color: rgb("#4f46e5"), qr-caption: none) +#exo( + exercise: [ + Solve the system of equations + $ cases(x + 2y = 3, 4x - y = 1) $ + and interpret the result geometrically. The exercise text wraps + around the QR code placed at the top right of the card. + ], + qr: "https://example.com/corrections/exo-3", +) diff --git a/packages/preview/exercise-bank/0.7.0/gallery/references-1.svg b/packages/preview/exercise-bank/0.7.0/gallery/references-1.svg new file mode 100644 index 0000000000..0aa9a80447 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/references-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/references-2.svg b/packages/preview/exercise-bank/0.7.0/gallery/references-2.svg new file mode 100644 index 0000000000..e1836f6733 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/references-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/references.typ b/packages/preview/exercise-bank/0.7.0/gallery/references.typ new file mode 100644 index 0000000000..ff873e1e7d --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/references.typ @@ -0,0 +1,17 @@ +#import "@preview/exercise-bank:0.7.0": exo, exo-setup, exo-cite +#import "@preview/beautitled:0.3.1": beautitled-init, beautitled-setup +#set page(width: 16cm, height: auto, margin: 1cm, numbering: "1") +#set text(size: 10pt, lang: "fr") +#show: beautitled-init +#beautitled-setup(enable-parts: true, part-fullpage: false, part-prefix: "Partie") +#exo-setup(display: "ex", badge-style: "underline", exercise-label: "Exercice", number-prefix: "chapter") + += Algèbre +== Équations +On utilisera plus loin #exo-cite("pythagoras", show-part: true). +#exo(id: "linear", exercise: [Résoudre $2x + 5 = 13$.]) + += Géométrie +== Triangles +#exo(id: "pythagoras", exercise: [Calculer l’hypoténuse d’un triangle rectangle de côtés 3 et 4.]) +Voir aussi #exo-cite("linear", show-part: true). diff --git a/packages/preview/exercise-bank/0.7.0/gallery/section-numbering.svg b/packages/preview/exercise-bank/0.7.0/gallery/section-numbering.svg new file mode 100644 index 0000000000..e764bcb1fb --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/section-numbering.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/section-numbering.typ b/packages/preview/exercise-bank/0.7.0/gallery/section-numbering.typ new file mode 100644 index 0000000000..78a71eebb2 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/section-numbering.typ @@ -0,0 +1,24 @@ +// Per-section numbering and corrections - exercise-bank +// number-prefix "section" + exo-auto-chapter: nothing to call by hand + +#import "@preview/exercise-bank:0.7.0": * + +#set page(width: 14cm, height: auto, margin: 1cm) +#set text(font: "New Computer Modern", size: 11pt, lang: "fr") +#set heading(numbering: "1.1") + +#show: exo-auto-chapter +#exo-setup( + number-prefix: "section", // 1.2.1 = exercise 1 of section 1.2 + corr-loc: "end-section", // solutions before the next section + exercise-label: "Exercice", + label-font-size: 10pt, +) + += Équations +== Premier degré +#exo(exercise: [Résoudre $2x + 3 = 7$.], solution: [$x = 2$]) +#exo(exercise: [Résoudre $5 - x = 1$.], solution: [$x = 4$]) + +== Second degré +#exo(exercise: [Résoudre $x^2 = 9$.], solution: [$x in {-3, 3}$]) diff --git a/packages/preview/exercise-bank/0.7.0/gallery/solutions.svg b/packages/preview/exercise-bank/0.7.0/gallery/solutions.svg new file mode 100644 index 0000000000..8cb1eada02 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/solutions.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/solutions.typ b/packages/preview/exercise-bank/0.7.0/gallery/solutions.typ new file mode 100644 index 0000000000..43da68c0be --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/solutions.typ @@ -0,0 +1,36 @@ +#import "@preview/exercise-bank:0.7.0": exo, exo-setup + +#set page(width: 14cm, height: auto, margin: 1cm) + +// Default: display: "both", corr-display: "solution", corr-loc: "after" + += Exercises with Solutions + +#exo( + exercise: [ + Solve the equation $2x + 5 = 13$. + ], + solution: [ + $2x + 5 = 13$\ + $2x = 8$\ + $x = 4$ + ], +) + +#exo( + exercise: [ + Calculate the area of a rectangle with length 8 cm and width 5 cm. + ], + solution: [ + Area $= l times w = 8 times 5 = 40$ cm² + ], +) + +#exo( + exercise: [ + Factor the expression $x^2 - 9$. + ], + solution: [ + $x^2 - 9 = (x + 3)(x - 3)$ + ], +) diff --git a/packages/preview/exercise-bank/0.7.0/gallery/styles.svg b/packages/preview/exercise-bank/0.7.0/gallery/styles.svg new file mode 100644 index 0000000000..53df240cd6 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/styles.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/styles.typ b/packages/preview/exercise-bank/0.7.0/gallery/styles.typ new file mode 100644 index 0000000000..4b18c0dd30 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/styles.typ @@ -0,0 +1,45 @@ +// Visual Styles Gallery - exercise-bank +// All 12 badge styles with attractive colors + +#import "@preview/exercise-bank:0.7.0": * + +#set page(margin: 1.5cm) +#set text(font: "New Computer Modern", size: 11pt) + += Visual Styles + +#exo-setup(badge-style: "box") +#exo(exercise: [*Box* (default) - Solve $x + 2 = 5$]) + +#exo-setup(badge-style: "circled") +#exo(exercise: [*Circled* - Clean and minimal]) + +#exo-setup(badge-style: "filled-circle", badge-color: rgb("#2563eb")) +#exo(exercise: [*Filled Circle* - High visibility]) + +#exo-setup(badge-style: "rect", badge-color: rgb("#0f766e")) +#exo(exercise: [*Rect* - Minimal, number only, compact]) + +#exo-setup(badge-style: "filled-rect", badge-color: rgb("#b91c1c")) +#exo(exercise: [*Filled Rect* - Minimal with high visibility]) + +#exo-setup(badge-style: "pill", badge-color: rgb("#059669")) +#exo(exercise: [*Pill* - Modern and friendly]) + +#exo-setup(badge-style: "tag", badge-color: rgb("#7c3aed")) +#exo(exercise: [*Tag* - Callout style]) + +#exo-setup(badge-style: "margin", badge-color: rgb("#374151")) +#exo(exercise: [*Margin* - Side label with rule, content flows to the right]) + +#exo-setup(badge-style: "border-accent", badge-color: rgb("#d97706")) +#exo(exercise: [*Border Accent* - Document sections]) + +#exo-setup(badge-style: "underline", badge-color: rgb("#dc2626")) +#exo(exercise: [*Underline* - Classic textbook]) + +#exo-setup(badge-style: "rounded-box", badge-color: rgb("#0891b2")) +#exo(exercise: [*Rounded Box* - Clean worksheets]) + +#exo-setup(badge-style: "header-card", badge-color: rgb("#4f46e5")) +#exo(exercise: [*Header Card* - Standalone exercises]) diff --git a/packages/preview/exercise-bank/0.7.0/gallery/titles-worked.svg b/packages/preview/exercise-bank/0.7.0/gallery/titles-worked.svg new file mode 100644 index 0000000000..14677d85e5 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/titles-worked.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/titles-worked.typ b/packages/preview/exercise-bank/0.7.0/gallery/titles-worked.typ new file mode 100644 index 0000000000..6fca85cf0e --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/titles-worked.typ @@ -0,0 +1,29 @@ +// Titles, worked examples and compact headers - exercise-bank +// Student version (display: "ex"): only the worked example keeps its solution + +#import "@preview/exercise-bank:0.7.0": * + +#set page(width: 14cm, height: auto, margin: 1cm) +#set text(font: "New Computer Modern", size: 11pt, lang: "fr") + +#exo-setup( + display: "ex", // student version: solutions hidden... + badge-style: "underline", + exercise-label: "Exercice", + header-rule-gap: 0.3em, // tighter header + header-body-gap: 0.6em, +) + +#exo( + title: [Exemple corrigé], + worked: true, // ...except for this worked example + exercise: [Simplifier $e^3 times e^4$.], + solution: [$e^3 times e^4 = e^(3+4) = e^7$], +) + +#exo( + title: [Calculer une longueur avec le théorème de Pythagore], + exercise: [Dans le triangle $A B C$ rectangle en $B$, $A B = 3$ et $B C = 4$. + Calculer $A C$.], + solution: [$A C = sqrt(3^2 + 4^2) = 5$], +) diff --git a/packages/preview/exercise-bank/0.7.0/gallery/two-columns.svg b/packages/preview/exercise-bank/0.7.0/gallery/two-columns.svg new file mode 100644 index 0000000000..fa7bb7ef37 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/two-columns.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/two-columns.typ b/packages/preview/exercise-bank/0.7.0/gallery/two-columns.typ new file mode 100644 index 0000000000..de63710854 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/two-columns.typ @@ -0,0 +1,45 @@ +// Two-column layouts - exercise-bank +// exo-columns puts a block of exercises on two columns with a vertical rule; +// corr-columns does the same for the corrections collected at the chapter end + +#import "@preview/exercise-bank:0.7.0": * + +#set page(width: 13cm, height: auto, margin: 1cm) +#set text(font: "New Computer Modern", size: 9.5pt) +#set par(justify: true) + +#exo-setup( + exercise-label: "Exercice", + correction-label: "Corrigé", + badge-style: "filled-rect", + badge-color: rgb("#1a5276"), + corr-display: "correction", + corr-loc: "end-section", + // The collected corrections get their own two columns, with the same rule + corr-columns: 2, + corr-columns-gutter: 0.7cm, + corr-columns-rule: 0.5pt + luma(160), +) + +#exo-columns(count: 2, gutter: 0.7cm, rule: 0.5pt + luma(160))[ + #exo( + exercise: [Trouver deux sous-ensembles $A$ et $B$ de $ZZ$ tels que + $A union B = {0; 1; 2}$ et $A inter B = emptyset$.], + correction: [$A = {0}$ et $B = {1; 2}$, par exemple.], + ) + #exo( + exercise: [Résoudre $x^2 - 5x + 6 = 0$.], + correction: [$Delta = 1$, donc $S = {2; 3}$.], + ) + #exo( + exercise: [Écrire $[-1; 4[ inter [2; +oo[$ sous forme d'intervalle.], + correction: [$[2; 4[$.], + ) + #exo( + exercise: [Résoudre $sqrt(x + 4) = x - 2$, sans oublier de vérifier les + solutions trouvées.], + correction: [$x = 5$ ($x = 0$ est parasite).], + ) +] + +#exo-section-end() diff --git a/packages/preview/exercise-bank/0.7.0/lib.typ b/packages/preview/exercise-bank/0.7.0/lib.typ new file mode 100644 index 0000000000..a13bd28d3b --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/lib.typ @@ -0,0 +1,3590 @@ +// exercise-bank - Exercise Package for Typst +// Handles exercises with metadata, content, and solutions +// Supports multiple display modes and filtering + +// ============================================================================= +// External Package Imports +// ============================================================================= + +#import "@preview/tiaoma:0.3.0": qrcode as _tiaoma-qrcode +#import "@preview/wrap-it:0.1.1": wrap-content as _wrap-content + +// ============================================================================= +// State and Counters +// ============================================================================= + +#let optional-star-icon(size: 0.62em, fill: black) = box(width: size, height: size, inset: 0pt)[ + #polygon( + fill: fill, + (0.50 * size, 0.00 * size), + (0.62 * size, 0.34 * size), + (0.98 * size, 0.35 * size), + (0.69 * size, 0.56 * size), + (0.79 * size, 0.91 * size), + (0.50 * size, 0.70 * size), + (0.21 * size, 0.91 * size), + (0.31 * size, 0.56 * size), + (0.02 * size, 0.35 * size), + (0.38 * size, 0.34 * size), + ) +] + +// Diagonal dumbbell (haltère, fitness-icon convention): marks exercises whose +// correction will be handed out — students train on their own with the corrigé. +// Solid silhouette, readable at badge size; students learn the icon, no +// explanatory text needed. +#let corr-given-icon(size: 0.75em) = box(width: size, height: size, inset: 0pt)[ + #place(top + left, rotate(-45deg, origin: center, box(width: size, height: size)[ + #place(top + left, dx: 0.02 * size, dy: 0.28 * size, + rect(width: 0.17 * size, height: 0.44 * size, fill: black, radius: 0.04 * size)) + #place(top + left, dx: 0.81 * size, dy: 0.28 * size, + rect(width: 0.17 * size, height: 0.44 * size, fill: black, radius: 0.04 * size)) + #place(top + left, dx: 0.17 * size, dy: 0.425 * size, + rect(width: 0.66 * size, height: 0.15 * size, fill: black)) + ])) +] + +// Difficulty icons, one per level of the default scale (same drawn-icon +// convention as the markers above). The level-to-icon mapping is fully +// configurable via difficulty-scale. + +// Level 1 (introductory): seedling +#let difficulty-seedling-icon(size: 0.75em, fill: black) = box(width: size, height: size, inset: 0pt)[ + #place(top + left, dx: 0.46 * size, dy: 0.40 * size, + rect(width: 0.10 * size, height: 0.55 * size, fill: fill)) + #place(top + left, polygon(fill: fill, + (0.51 * size, 0.52 * size), + (0.10 * size, 0.44 * size), + (0.24 * size, 0.10 * size), + )) + #place(top + left, polygon(fill: fill, + (0.51 * size, 0.52 * size), + (0.92 * size, 0.44 * size), + (0.78 * size, 0.10 * size), + )) +] + +// Level 2 (standard): pencil +#let difficulty-pencil-icon(size: 0.75em, fill: black) = box(width: size, height: size, inset: 0pt)[ + #place(top + left, rotate(45deg, origin: center, box(width: size, height: size)[ + #place(top + left, dx: 0.40 * size, dy: 0.02 * size, + rect(width: 0.20 * size, height: 0.62 * size, fill: fill, radius: (top: 0.05 * size))) + #place(top + left, polygon(fill: fill, + (0.40 * size, 0.70 * size), + (0.60 * size, 0.70 * size), + (0.50 * size, 0.96 * size), + )) + ])) +] + +// Level 3 (exam-type): target +#let difficulty-target-icon(size: 0.75em, fill: black) = box(width: size, height: size, inset: 0pt)[ + #place(center + horizon, circle(radius: 0.42 * size, stroke: 0.09 * size + fill)) + #place(center + horizon, circle(radius: 0.14 * size, fill: fill)) +] + +// Level 4 (advanced): mountain +#let difficulty-mountain-icon(size: 0.75em, fill: black) = box(width: size, height: size, inset: 0pt)[ + #place(top + left, polygon(fill: fill, + (0.00 * size, 0.90 * size), + (0.36 * size, 0.16 * size), + (0.60 * size, 0.62 * size), + (0.50 * size, 0.90 * size), + )) + #place(top + left, polygon(fill: fill, + (0.34 * size, 0.90 * size), + (0.66 * size, 0.34 * size), + (1.00 * size, 0.90 * size), + )) +] + +// Level 5 (expert): star +#let difficulty-star-icon(size: 0.72em, fill: black) = optional-star-icon(size: size, fill: fill) + +// Arrow icons for the clickable exercise <-> correction links +#let solution-link-icon(size: 0.7em, fill: black) = box(width: size, height: size, inset: 0pt)[ + #place(top + left, dx: 0pt, dy: 0.40 * size, + rect(width: 0.55 * size, height: 0.20 * size, fill: fill)) + #place(top + left, polygon(fill: fill, + (0.50 * size, 0.14 * size), + (1.00 * size, 0.50 * size), + (0.50 * size, 0.86 * size), + )) +] + +#let solution-backlink-icon(size: 0.7em, fill: black) = box(width: size, height: size, inset: 0pt)[ + #place(top + left, polygon(fill: fill, + (0.50 * size, 0.14 * size), + (0.00 * size, 0.50 * size), + (0.50 * size, 0.86 * size), + )) + #place(top + left, dx: 0.45 * size, dy: 0.40 * size, + rect(width: 0.55 * size, height: 0.20 * size, fill: fill)) +] + +// Built-in 5-level difficulty scale: keys are strings ("1".."5"); each entry +// provides the badge color ("color" mode) and a tinted icon ("symbols" mode). +#let default-difficulty-scale = ( + "1": (color: rgb("#2e7d32"), symbol: difficulty-seedling-icon(fill: rgb("#2e7d32"))), + "2": (color: rgb("#c62828"), symbol: difficulty-pencil-icon(fill: rgb("#c62828"))), + "3": (color: rgb("#1565c0"), symbol: difficulty-target-icon(fill: rgb("#1565c0"))), + "4": (color: rgb("#6a1b9a"), symbol: difficulty-mountain-icon(fill: rgb("#6a1b9a"))), + "5": (color: rgb("#212121"), symbol: difficulty-star-icon(fill: rgb("#212121"))), +) + +// Global exercise counter +#let exo-counter = counter("exo-counter") + +// Global display counter: never reset, used to build document-unique label +// names for the exercise <-> correction links (exo-counter restarts per +// section, so its values collide across chapters) +#let exo-display-counter = counter("exo-display-counter") + +// Configuration state +#let exo-config = state("exo-config", ( + // Display control + "display": "both", // "ex" (exercises only), "sol" (solutions/corrections only), "both" + "corrDisplay": "solution", // "solution", "correction", "mixed" + "corrLoc": "after", // "after", "pagebreak", "end-section", "end-chapter" + "sol-loc": auto, // location for solutions only; auto = follow corrLoc + // (e.g. sol-loc: "after" + corr-loc: "end-chapter") + // Labels + "solution-label": "Solution", + "correction-label": "Correction", + "exercise-label": "Exercise", + // Counter behavior + "counter-reset": "section", // "section", "chapter", "global" + "number-prefix": none, // none, or "heading" to prefix exercise numbers with the + // current level-1 heading number (e.g. "3.5") + "number-separator": ".", // Separator between chapter prefix and exercise number + "number-prefix-depth": 1, // With number-prefix "heading": how many heading levels + // make up the prefix (2 = "3.2" for section 2 of chapter 3) + // Exercise titles + "title-separator": [ -- ], // Between "Exercise 1" and the title + "title-format": auto, // auto, or function (title) => content + "title-in-solutions": false, // Repeat the title on solution/correction boxes + // Header spacing (full-width styles and badge-position "above"; auto = style default) + "underline-gap": auto, // Header lower edge -> rule centre; auto follows header-rule-gap + "underline-below": auto, // Rule centre -> body upper edge; auto follows header-body-gap + "header-rule-gap": auto, // underline style: space between the label and the rule + "header-body-gap": auto, // Space between the header (label/rule) and the statement + // Display options + "show-metadata": false, + "show-id": false, // Show exercise UID below the badge + "show-competencies": false, // Show competency tags below exercise + "display-mode": "exercise", // "exercise" (default exo-box) or "exam" (g-exam question) + // Visual styling + "badge-style": "box", // "box", "circled", "filled-circle", "pill", "tag", "margin", "border-accent", "underline", "rounded-box", "header-card" + "badge-position": "margin", // Where the badge sits relative to the statement: + // "margin" (own left column, the text is indented for the + // whole height of the exercise) or "above" (badge alone on a + // header line, the text runs the full width underneath — + // saves a lot of width in narrow columns and with enumerations) + "badge-color": black, // Color for exercise badges + "solution-color": rgb("#4a7c59"), // Color for solution badges (green) + "correction-color": rgb("#4a7c59"), // Color for correction badges (green) + "label-font-size": 12pt, // Font size for badge label text + "margin-position": auto, // Position of content margin from left (auto = computed from label size) + "label-extra": 1cm, // Extra space for labels to extend into left margin + "badge-scale": 1.0, // Multiplies the built-in badge paddings (and the diameter of + // the circle styles): 0.6 for discreet badges, 1.5 for big ones. + // The label text keeps its own size (label-font-size) + "badge-pad-x": auto, // Explicit horizontal padding inside the badge (overrides badge-scale) + "badge-pad-y": auto, // Explicit vertical padding; the badge height is + // label-font-size + 2 * badge-pad-y (overrides badge-scale) + "badge-radius": auto, // Explicit corner radius (auto = the style's own, scaled) + "margin-label-width": 3.35cm, // Width of the side-label column of badge-style "margin" + "margin-label-gutter": 0.55cm, // Gap between that column and the statement + "margin-fold-below": auto, // Below this measure the "margin" style folds its side label + // onto a header line so the statement keeps the full width + // (auto = 3 * margin-label-width + gutter; 0pt = never fold). + // badge-position "above" folds it whatever the width + "page-break": "none", // "none", "before", "after", "around" - page break behavior for exercises + // Draft mode + "draft-mode": false, // Show placeholders for empty corrections/solutions + "correction-placeholder": [_To be completed_], // Placeholder when correction is empty (draft mode) + "solution-placeholder": [_To be completed_], // Placeholder when solution is empty (draft mode) + // Spacing parameters + "exercise-above": 0.8em, // Space above exercise boxes + "exercise-below": 0.8em, // Space below exercise boxes + "solution-above": 0.8em, // Space above solution boxes + "solution-below": 0.8em, // Space below solution boxes + "correction-above": 0.8em, // Space above correction boxes + "correction-below": 0.8em, // Space below correction boxes + "advanced-symbol": "*", // Symbol shown before label for advanced exercises (none to disable) + "optional-symbol": optional-star-icon(), // Symbol shown before label for optional exercises (none to disable) + "corr-given-symbol": corr-given-icon(), // Symbol shown before label when the correction will be handed out (none to disable) + // Difficulty encoding + "difficulty-display": "color", // How the difficulty: level shows on exercises: + // "color" (badge takes the level color), + // "stars" (N small stars before the label, numeric levels), + // "symbols" (one icon per level), "none" + "difficulty-scale": auto, // auto = built-in 5-level scale; or dict key -> (color: .., symbol: ..) + "difficulty-position": "below", // Where stars/symbols go: "below" the badge (keeps the + // badge compact) or "badge" (inline, before the label) + // Solution presentation + "solution-style": auto, // auto = badge box; "inline" = epigraph-like short rule + + // content right under the statement (only for sol-loc "after") + "inline-rule-length": 3cm, // Length of the rule above inline solutions + "inline-label": auto, // Small margin label next to inline solutions: + // auto = solution-label, none to hide, or custom content + // Deferred correction section (corr-loc "end-section" / "end-chapter") + "corr-columns": 1, // Number of columns the collected corrections are set in + "corr-columns-gutter": 4%, // Gutter between those columns + "corr-columns-rule": none, // Stroke for a vertical separator between them (e.g. 0.5pt + gray) + "corr-columns-badge-position": auto, // Badge position inside the correction columns: + // auto = "above" when corr-columns > 1 (badges cost no column + // width), or "margin" / "above" to force one + // Exercise <-> correction links (for deferred corrections) + "link-solutions": false, // Clickable icon on the exercise jumping to its deferred + // solution/correction, and a back-link on the correction + "link-icon": solution-link-icon(), // Icon next to the exercise badge (none to hide) + "backlink-icon": solution-backlink-icon(), // Icon next to the correction badge (none to hide) + "link-style": "icon", // "icon" (arrow next to the badge) or "page" + // ("Solution p. 30" textbook-style reference at the + // top right of the statement) + "page-ref-format": auto, // auto, or function (label, page) => content + "page-ref-color": auto, // Color of the page reference; auto = exercise badge color + // QR codes + "show-qr": true, // Master toggle for per-exercise QR codes + "qr-size": 1.5cm, // Target QR code size (shrinks to fit the label margin if needed) + "qr-min-size": 1cm, // Never shrink below this (a smaller QR is hard to scan); + // if the label margin is narrower, the QR extends into the page margin + "qr-color": black, // QR module color + "qr-caption": none, // Optional small caption below every QR code (e.g. [Corrigé]) + "qr-position": "auto", // "auto" (per badge style: label margin or wrapped), + // "wrap" (always wrap the exercise content around the QR) or + // "tasks" (overlay the QR; a taskize #tasks body flows around it) +)) + +// Registry of all exercises (for filtering) +#let exo-registry = state("exo-registry", ()) + +// Exercises pending solution display (for end-section/end-chapter modes) +#let exo-pending-solutions = state("exo-pending-solutions", ()) + +// ID counter for auto-generation +#let exo-id-counter = counter("exo-id-counter") + +// Exam configuration state (for exam display mode) +#let exam-config = state("exam-config", ( + "show-solutions": false, // Whether to show solutions in exam + "solution-label": "Solution", + // Solution box styling + "solution-fill": rgb("#e8f5e9"), + "solution-stroke": 0.8pt + rgb("#4a7c59"), + "solution-radius": 3pt, + "solution-inset": 8pt, + "solution-label-color": rgb("#2e5a3a"), + // Question styling (for custom question wrappers) + "question-spacing": 1em, +)) + +// Custom solution box for exams (fully customizable) +// Defined early so it's available in exo-show/exo-select when in exam mode +#let exam-solution-box(body) = context { + let exam-cfg = exam-config.get() + if exam-cfg.show-solutions { + block( + width: 100%, + inset: exam-cfg.solution-inset, + fill: exam-cfg.solution-fill, + stroke: exam-cfg.solution-stroke, + radius: exam-cfg.solution-radius, + [ + #text(weight: "bold", fill: exam-cfg.solution-label-color)[#exam-cfg.solution-label:] + #v(4pt) + #body + ] + ) + } +} + +// ============================================================================= +// Configuration Function +// ============================================================================= + +#let exo-setup( + // Display control + display: none, // "ex", "sol", "both" + corr-display: none, // "solution", "correction", "mixed" + corr-loc: none, // "after", "pagebreak", "end-section", "end-chapter" + sol-loc: none, // same values as corr-loc, or auto (= follow corr-loc); solutions only + // Labels + solution-label: none, + correction-label: none, + exercise-label: none, + // Counter behavior + counter-reset: none, + number-prefix: auto, // auto = keep current; none, or "heading" for chapter-prefixed numbers + number-separator: none, // Separator between chapter prefix and number (default ".") + number-prefix-depth: none, // Heading levels in a "heading" prefix (default 1) + // Exercise titles + title-separator: auto, // Between "Exercise 1" and the title (auto = keep current) + title-format: none, // auto or function (title) => content + title-in-solutions: none, // Repeat the title on solution/correction boxes + // Header spacing + underline-gap: none, // Header lower edge -> rule centre (auto = legacy option/default) + underline-below: none, // Rule centre -> body upper edge (auto = legacy option/default) + header-rule-gap: none, // underline style: label -> rule (auto = style default) + header-body-gap: none, // header -> statement (auto = style default) + // Display options + show-metadata: none, + show-id: none, + show-competencies: none, + display-mode: none, // "exercise" or "exam" + // Visual styling + badge-style: none, // "box", "circled", "filled-circle", "pill", "tag", "margin", "border-accent", "underline", "rounded-box", "header-card" + badge-position: none, // "margin" (badge in its own left column) or "above" + // (badge on a header line, statement full width below) + badge-color: none, // Color for exercise badges + solution-color: none, // Color for solution badges + correction-color: none, // Color for correction badges + label-font-size: none, + badge-scale: none, // Multiplies the built-in badge paddings (1.0 = default size) + badge-pad-x: none, // Explicit badge padding, overriding badge-scale + badge-pad-y: none, // (auto puts the shape's own value back) + badge-radius: none, // Explicit badge corner radius (auto = the style's own) + margin-label-width: none, // Side-label column of badge-style "margin" + margin-label-gutter: none, + margin-fold-below: none, // Fold that side label onto a header line below this + // measure (auto = computed; 0pt = never fold) + margin-position: none, + label-extra: none, + page-break: none, // "none", "before", "after", "around" + // Draft mode + draft-mode: none, + correction-placeholder: none, + solution-placeholder: none, + // Spacing parameters + exercise-above: none, + exercise-below: none, + solution-above: none, + solution-below: none, + correction-above: none, + correction-below: none, + advanced-symbol: none, // Symbol for advanced exercises (use "*", "†", emoji, etc.) + optional-symbol: none, // Symbol for optional exercises (use [★], [⛰], etc.) + corr-given-symbol: none, // Symbol for exercises whose correction is handed out + // Difficulty encoding + difficulty-display: none, // "color", "stars", "symbols", "none" + difficulty-scale: none, // auto (built-in scale) or dict key -> (color: .., symbol: ..) + difficulty-position: none, // "below" (under the badge) or "badge" (inline) + // Solution presentation + solution-style: none, // auto (badge box) or "inline" (epigraph-like rule) + inline-rule-length: none, // Length of the rule above inline solutions + inline-label: auto, // Small margin label for inline solutions (none to hide) + // Deferred correction section + corr-columns: none, // Number of columns for the collected corrections + corr-columns-gutter: none, + corr-columns-rule: auto, // Vertical separator between them, e.g. 0.5pt + gray + // (none removes a separator set earlier) + corr-columns-badge-position: auto, // auto = "above" as soon as there are 2+ columns + // Exercise <-> correction links + link-solutions: none, // true/false + link-icon: auto, // Icon on the exercise (auto = keep current, none to hide) + backlink-icon: auto, // Icon on the correction (auto = keep current, none to hide) + link-style: none, // "icon" or "page" ("Solution p. 30" reference) + page-ref-format: none, // auto or function (label, page) => content + page-ref-color: none, // Color of the page reference (auto = badge color) + // QR codes + show-qr: none, // Master toggle for per-exercise QR codes + qr-size: none, // Target QR code size + qr-min-size: none, // Minimum QR code size (may overflow narrow label margins) + qr-color: none, // QR module color + qr-caption: auto, // Small caption below every QR code (none to remove) + qr-position: none, // "auto" (per badge style), "wrap" (always wrap content) + // or "tasks" (overlay; taskize #tasks bodies flow around the QR) +) = { + exo-config.update(cfg => { + let new = cfg + if display != none { new.display = display } + if corr-display != none { new.corrDisplay = corr-display } + if corr-loc != none { new.corrLoc = corr-loc } + if sol-loc != none { new.sol-loc = sol-loc } + if solution-label != none { new.solution-label = solution-label } + if correction-label != none { new.correction-label = correction-label } + if exercise-label != none { new.exercise-label = exercise-label } + if counter-reset != none { new.counter-reset = counter-reset } + if number-prefix != auto { new.number-prefix = number-prefix } + if number-separator != none { new.number-separator = number-separator } + if number-prefix-depth != none { new.number-prefix-depth = number-prefix-depth } + if title-separator != auto { new.title-separator = title-separator } + if title-format != none { new.title-format = title-format } + if title-in-solutions != none { new.title-in-solutions = title-in-solutions } + if underline-gap != none { new.underline-gap = underline-gap } + if underline-below != none { new.underline-below = underline-below } + if header-rule-gap != none { new.header-rule-gap = header-rule-gap } + if header-body-gap != none { new.header-body-gap = header-body-gap } + if show-metadata != none { new.show-metadata = show-metadata } + if show-id != none { new.show-id = show-id } + if show-competencies != none { new.show-competencies = show-competencies } + if display-mode != none { new.display-mode = display-mode } + if badge-style != none { new.badge-style = badge-style } + if badge-position != none { new.badge-position = badge-position } + if badge-color != none { new.badge-color = badge-color } + if solution-color != none { new.solution-color = solution-color } + if correction-color != none { new.correction-color = correction-color } + if label-font-size != none { new.label-font-size = label-font-size } + if badge-scale != none { new.badge-scale = badge-scale } + if badge-pad-x != none { new.badge-pad-x = badge-pad-x } + if badge-pad-y != none { new.badge-pad-y = badge-pad-y } + if badge-radius != none { new.badge-radius = badge-radius } + if margin-label-width != none { new.margin-label-width = margin-label-width } + if margin-label-gutter != none { new.margin-label-gutter = margin-label-gutter } + if margin-fold-below != none { new.margin-fold-below = margin-fold-below } + if margin-position != none { new.margin-position = margin-position } + if label-extra != none { new.label-extra = label-extra } + if page-break != none { new.page-break = page-break } + if draft-mode != none { new.draft-mode = draft-mode } + if correction-placeholder != none { new.correction-placeholder = correction-placeholder } + if solution-placeholder != none { new.solution-placeholder = solution-placeholder } + // Spacing parameters + if exercise-above != none { new.exercise-above = exercise-above } + if exercise-below != none { new.exercise-below = exercise-below } + if solution-above != none { new.solution-above = solution-above } + if solution-below != none { new.solution-below = solution-below } + if correction-above != none { new.correction-above = correction-above } + if correction-below != none { new.correction-below = correction-below } + if advanced-symbol != none { new.advanced-symbol = advanced-symbol } + if optional-symbol != none { new.optional-symbol = optional-symbol } + if corr-given-symbol != none { new.corr-given-symbol = corr-given-symbol } + if difficulty-display != none { new.difficulty-display = difficulty-display } + if difficulty-scale != none { new.difficulty-scale = difficulty-scale } + if difficulty-position != none { new.difficulty-position = difficulty-position } + if solution-style != none { new.solution-style = solution-style } + if inline-rule-length != none { new.inline-rule-length = inline-rule-length } + if inline-label != auto { new.inline-label = inline-label } + if corr-columns != none { new.corr-columns = corr-columns } + if corr-columns-gutter != none { new.corr-columns-gutter = corr-columns-gutter } + if corr-columns-rule != auto { new.corr-columns-rule = corr-columns-rule } + if corr-columns-badge-position != auto { new.corr-columns-badge-position = corr-columns-badge-position } + if link-solutions != none { new.link-solutions = link-solutions } + if link-icon != auto { new.link-icon = link-icon } + if backlink-icon != auto { new.backlink-icon = backlink-icon } + if link-style != none { new.link-style = link-style } + if page-ref-format != none { new.page-ref-format = page-ref-format } + if page-ref-color != none { new.page-ref-color = page-ref-color } + if show-qr != none { new.show-qr = show-qr } + if qr-size != none { new.qr-size = qr-size } + if qr-min-size != none { new.qr-min-size = qr-min-size } + if qr-color != none { new.qr-color = qr-color } + if qr-caption != auto { new.qr-caption = qr-caption } + if qr-position != none { new.qr-position = qr-position } + new + }) + // A literal prefix starts a new series ("Série 3" -> 3.1, 3.2, ...): the + // numbering restarts with it + if type(number-prefix) in (int, content) or (type(number-prefix) == str and number-prefix not in ("heading", "chapter", "section")) { + exo-counter.update(0) + } +} + +// ============================================================================= +// Counter Reset Hooks +// ============================================================================= + +#let exo-reset-counter() = { + exo-counter.update(0) +} + +#let exo-section-start() = { + context { + let cfg = exo-config.get() + if cfg.counter-reset == "section" { + exo-counter.update(0) + } + } +} + +#let exo-chapter-start() = { + context { + let cfg = exo-config.get() + if cfg.counter-reset == "chapter" or cfg.counter-reset == "section" { + exo-counter.update(0) + } + } + exo-pending-solutions.update(()) +} + +// ============================================================================= +// Advanced Exercise Label Helper +// ============================================================================= + +// Build exercise label with optional advanced symbol prefix +#let get-exercise-label(cfg, metadata) = { + let label = cfg.exercise-label + if cfg.advanced-symbol != none { + let is-advanced = metadata.at("advanced", default: false) + if is-advanced { + [#cfg.advanced-symbol~#label] + } else { + label + } + } else { + label + } +} + +// ============================================================================= +// Difficulty Helpers +// ============================================================================= + +// Resolve the difficulty scale entry (color/symbol dict) for an exercise +#let get-difficulty-entry(cfg, metadata) = { + let diff = metadata.at("difficulty", default: none) + if diff == none { return none } + let scale = cfg.at("difficulty-scale", default: auto) + if scale == auto { scale = default-difficulty-scale } + scale.at(str(diff), default: none) +} + +// Badge color for an exercise: the difficulty color in "color" mode, +// otherwise auto (= use the configured badge-color) +#let get-exercise-badge-color(cfg, metadata) = { + if cfg.at("difficulty-display", default: "color") == "color" { + let entry = get-difficulty-entry(cfg, metadata) + if entry != none and entry.at("color", default: none) != none { + return entry.color + } + } + auto +} + +// Difficulty marker for "stars"/"symbols" display modes (none otherwise) +#let get-difficulty-marker(cfg, metadata) = { + let mode = cfg.at("difficulty-display", default: "color") + if mode != "stars" and mode != "symbols" { return none } + let diff = metadata.at("difficulty", default: none) + if diff == none { return none } + let entry = get-difficulty-entry(cfg, metadata) + let color = if entry != none { entry.at("color", default: black) } else { black } + if mode == "stars" { + // One small star per level; requires a numeric difficulty + if str(diff).match(regex("^[0-9]+$")) == none { return none } + let n = calc.min(int(str(diff)), 5) + if n < 1 { return none } + box(stack(dir: ltr, spacing: 0.06em, + ..range(n).map(_ => optional-star-icon(size: 0.52em, fill: color)))) + } else if entry != none { + entry.at("symbol", default: none) + } +} + +// Difficulty marker rendered below the badge (difficulty-position: "below", +// the default: keeps the badge compact instead of widening the label margin) +#let get-difficulty-badge-sub(cfg, metadata) = { + if cfg.at("difficulty-position", default: "below") == "below" { + get-difficulty-marker(cfg, metadata) + } +} + +#let get-exercise-marker(cfg, metadata, extra: none) = { + let markers = () + if cfg.at("difficulty-position", default: "below") == "badge" { + let diff-marker = get-difficulty-marker(cfg, metadata) + if diff-marker != none { + markers.push(diff-marker) + } + } + if cfg.optional-symbol != none and metadata.at("optional", default: false) { + markers.push(cfg.optional-symbol) + } + if cfg.at("corr-given-symbol", default: none) != none and metadata.at("corr-given", default: false) { + markers.push(cfg.at("corr-given-symbol")) + } + if extra != none { + markers.push(extra) + } + if markers.len() > 0 { markers.join(h(2.5pt)) } +} + +// ============================================================================= +// Number Formatting +// ============================================================================= + +// beautitled interop, without importing it: its state and counters are looked +// up by name. With `enable-parts`, `=` is a part and `==` a chapter. +// Must be called inside context. +#let _beautitled-parts() = { + // A state's initial value is the one given where it is read, and + // beautitled's update closures then run on it: it must be a dictionary they + // can assign keys into (they only ever assign, never read) + let c = state("beautitled-config", (enable-parts: false)).get() + type(c) == dictionary and c.at("enable-parts", default: false) == true +} + +// (chapter, section) numbers: beautitled's own counters once it has numbered a +// chapter (they stay right with parts enabled, where counter(heading) starts +// with the part), counter(heading) otherwise. Must be called inside context. +#let _chapter-section() = { + let chap = counter("beautitled-chapter").get().first() + if chap > 0 { + (chap, counter("beautitled-section").get().first()) + } else { + let h = counter(heading).get() + (h.at(0, default: 0), h.at(1, default: 0)) + } +} + +// Displayed exercise number, optionally prefixed by a chapter number. +// number-prefix may be "heading" (current heading number, number-prefix-depth +// levels deep), a counter (e.g. beautitled's chapter-counter, which bypasses +// counter(heading)), a function () => value evaluated in context, or a literal +// value (int, string or content: a series number such as 3). +// Must be called inside context. +#let format-exo-number(cfg, num) = { + let prefix = cfg.at("number-prefix", default: none) + if prefix == none { return [#num] } + let chap = if prefix == "chapter" { + _chapter-section().first() + } else if prefix == "section" { + // "chapter.section"; before the first section of a chapter, the chapter alone + let (c, s) = _chapter-section() + if s == 0 { c } else if c == 0 { s } else { str(c) + "." + str(s) } + } else if prefix == "heading" { + let depth = cfg.at("number-prefix-depth", default: 1) + let levels = counter(heading).get() + if depth <= 1 { + levels.first() + } else { + // Pad missing levels with 0 so "3.0.5" never silently becomes "3.5" + let parts = range(depth).map(i => levels.at(i, default: 0)) + if parts.all(p => p == 0) { 0 } else { parts.map(str).join(".") } + } + } else if type(prefix) == function { + prefix() + } else if type(prefix) == counter { + // Custom heading packages often keep their own + prefix.get().first() + } else { + prefix + } + let show-prefix = if chap == none { false } else if type(chap) == int { chap > 0 } else { true } + if show-prefix { + [#chap#cfg.at("number-separator", default: ".")#num] + } else { + [#num] + } +} + +// ============================================================================= +// References to displayed exercises +// ============================================================================= + +// Record the number actually passed to the badge, rather than reconstructing +// it from bank order. The zero-size marker lives in the number on the header, +// so its page follows page/column breaks and is never measured by wrap-it. +// Shared labels are queried as a collection; links use each marker's location. +#let _exo-reference-data(id, number, cfg, topic, group: none) = { + let part = none + let part-label = none + if _beautitled-parts() { + let num = counter("beautitled-part").get().first() + if num > 0 { + let part-cfg = state("beautitled-config", (enable-parts: false)).get() + let pattern = part-cfg.at("part-numbering", default: auto) + part = numbering(if pattern == auto { "I" } else { pattern }, num) + part-label = part-cfg.at("part-prefix", default: "Part") + } + } + ( + id: id, + number: number, + supplement: cfg.exercise-label, + group: group, + topic: topic, + part: part, + part-label: part-label, + ) +} + +#let _exo-reference-marker(info) = [#metadata(info) <_exb-reference>] + +// Cite a rendered exercise, forwards or backwards, within the compiled +// document. An optional label on exo/exo-show/exo-select/exo-filter selects that display +// call; occurrence selects among repeated displays (1-based, document order). +// Missing/hidden exercises produce an unlinked ?? placeholder. +#let exo-cite( + id, + pos-label: none, + topic: none, + occurrence: 1, + show-page: true, + show-part: false, + prefix: auto, + page-prefix: "p. ", + part-prefix: auto, + ..targets, +) = context { + assert(targets.named().len() == 0 and targets.pos().len() <= 1, + message: "exo-cite accepts at most one display label after the id") + let pos-label = if targets.pos().len() > 0 { + assert(pos-label == none, message: "exo-cite display label was supplied twice") + targets.pos().first() + } else { pos-label } + assert(type(occurrence) == int and occurrence > 0, + message: "exo-cite occurrence must be a positive integer") + assert(pos-label == none or type(pos-label) == label, + message: "exo-cite pos-label must be a label or none") + let hits = query(<_exb-reference>).filter(it => it.value.id == id) + if topic != none { + hits = hits.filter(it => it.value.topic == topic) + } + if pos-label != none { + let groups = query(pos-label) + if groups.len() == 1 { + // A label on the call attaches to its sequence, before the initial step. + let group = exo-display-counter.at(groups.first().location()).first() + 1 + hits = hits.filter(it => it.value.group == group) + } else { + hits = () + } + } + let hit = hits.at(occurrence - 1, default: none) + let found = hit != none + let info = if found { hit.value } else { ( + number: [??], supplement: exo-config.get().exercise-label, + part: none, part-label: none, + ) } + let supplement = if prefix == auto { info.supplement } else { prefix } + let body = if supplement in (none, []) { info.number } else { [#supplement~#info.number] } + let details = () + if show-page { + let page-text = if found { + let loc = hit.location() + let pattern = loc.page-numbering() + let pattern = if pattern == none { "1" } else { pattern } + if sys.version >= version(0, 15, 0) { + counter(page).display(pattern, at: loc) + } else { + numbering(pattern, ..counter(page).at(loc)) + } + } else { [??] } + details.push([#page-prefix#page-text]) + } + if show-part and info.part != none { + let supplement = if part-prefix == auto { info.part-label } else { part-prefix } + details.push(if supplement in (none, []) { info.part } else { [#supplement~#info.part] }) + } + if details.len() > 0 { body += [ (#details.join([, ]))] } + if found { link(hit.location(), body) } else { body } +} + +// ============================================================================= +// Visual Styling +// ============================================================================= + +// Calculate the maximum badge width for consistent margins +#let calc-badge-width(label, number, font-size) = { + let badge = box( + stroke: 0.8pt, + inset: (x: 4pt, y: 3pt), + text(weight: "bold", size: font-size)[#label #number], + ) + measure(badge).width +} + +// ============================================================================= +// QR Codes +// ============================================================================= + +// Build the QR code shown next to an exercise. +// `qr` is a URL string (rendered with tiaoma) or arbitrary content (used as-is). +// `max-width` caps the width so the QR shrinks to fit narrow label margins +// instead of overflowing them. +#let make-exo-qr(qr, cfg, max-width: none) = { + if qr == none or not cfg.at("show-qr", default: true) { return none } + let size = cfg.at("qr-size", default: 1.5cm) + let min-size = cfg.at("qr-min-size", default: 1cm) + if max-width != none and size > max-width { + // Shrink to fit the label margin, but never below min-size: a smaller QR + // is hard to scan, so past that point it extends into the page margin + size = calc.max(max-width, calc.min(min-size, size)) + } + if size <= 0pt { return none } + let code = if type(qr) == str { + _tiaoma-qrcode(qr, width: size, options: (fg-color: cfg.at("qr-color", default: black))) + } else { + box(width: size, qr) + } + let caption = cfg.at("qr-caption", default: none) + box({ + code + if caption != none { + v(2pt, weak: true) + align(center, box(width: size, text(size: 6.5pt, fill: luma(100), hyphenate: false, caption))) + } + }) +} + +// ============================================================================= +// Badge Styles +// ============================================================================= + +// Badge geometry ------------------------------------------------------------ +// Every built-in badge shape declares its own paddings and corner radius; the +// document scales them with `badge-scale` or replaces them outright with +// `badge-pad-x` / `badge-pad-y` / `badge-radius`. The label text is *not* +// touched here: its size stays `label-font-size`, so a smaller scale gives a +// tighter badge around the same number, not smaller digits. +#let badge-metrics(cfg) = ( + scale: cfg.at("badge-scale", default: 1.0), + pad-x: cfg.at("badge-pad-x", default: auto), + pad-y: cfg.at("badge-pad-y", default: auto), + radius: cfg.at("badge-radius", default: auto), +) + +// Resolve one shape's defaults against those settings. +#let badge-geom(metrics, pad-x: 4pt, pad-y: 4pt, radius: 0pt) = { + let scale = metrics.at("scale", default: 1.0) + let px = metrics.at("pad-x", default: auto) + let py = metrics.at("pad-y", default: auto) + let r = metrics.at("radius", default: auto) + ( + x: if px == auto { pad-x * scale } else { px }, + y: if py == auto { pad-y * scale } else { py }, + radius: if r == auto { radius * scale } else { r }, + ) +} + +// Style: box (default) - Rectangle with stroke +#let badge-box(label, number, font-size, color, is-solution, label-marker: none, metrics: (:)) = { + let g = badge-geom(metrics, pad-x: 4pt, pad-y: 4pt, radius: 0pt) + let fill-color = if is-solution { color.lighten(85%) } else { white } + let label-text = text(weight: "bold", size: font-size, fill: color)[#label~#number] + let label-content = if label-marker == none { + label-text + } else { + grid( + // auto width: the marker cell may hold several icons (e.g. ★ + ⊙) + columns: (auto, auto), + column-gutter: 3pt, + align: (center + horizon, left + horizon), + box(height: 0.75em, align(center + horizon, label-marker)), + label-text, + ) + } + box( + height: font-size + 2 * g.y, + stroke: 0.8pt + color, + fill: fill-color, + radius: g.radius, + inset: (x: g.x, y: 0pt), + )[ + #align(horizon)[#label-content] + ] +} + +// Style: circled - Number in a circle (no label) +#let badge-circled(label, number, font-size, color, is-solution, metrics: (:)) = { + let g = badge-geom(metrics, pad-x: font-size / 2, pad-y: font-size / 2) + let size = font-size + 2 * g.y + box( + width: size, + height: size, + stroke: 1.2pt + color, + radius: 50%, + align(center + horizon)[ + #text(weight: "bold", size: font-size, fill: color)[#number] + ] + ) +} + +// Style: filled-circle - Number in a filled circle +#let badge-filled-circle(label, number, font-size, color, is-solution, metrics: (:)) = { + let g = badge-geom(metrics, pad-x: font-size / 2, pad-y: font-size / 2) + let size = font-size + 2 * g.y + box( + width: size, + height: size, + fill: color, + radius: 50%, + align(center + horizon)[ + #text(weight: "bold", size: font-size, fill: white)[#number] + ] + ) +} + +// Style: pill - Rounded pill shape +#let badge-pill(label, number, font-size, color, is-solution, metrics: (:)) = { + let g = badge-geom(metrics, pad-x: 10pt, pad-y: 4pt, radius: 12pt) + let stroke-color = 0.8pt + color + let fill-color = if is-solution { color.lighten(85%) } else { rgb("#f3f4f6") } + box( + stroke: stroke-color, + fill: fill-color, + radius: g.radius, + inset: (x: g.x, y: g.y), + )[#text(weight: "medium", size: font-size, fill: color)[#label~#number]] +} + +// Style: tag - Arrow-shaped tag +#let badge-tag(label, number, font-size, color, is-solution, metrics: (:)) = { + let g = badge-geom(metrics, pad-x: 8pt, pad-y: 5pt, radius: 3pt) + let arrow-width = 8pt * metrics.at("scale", default: 1.0) + let tag-height = font-size + 2 * g.y + let fill-color = color + stack(dir: ltr, spacing: 0pt, + box( + height: tag-height, + fill: fill-color, + radius: (left: g.radius, right: 0pt), + inset: (x: g.x, y: 0pt), + )[ + #align(horizon)[ + #text(weight: "bold", size: font-size, fill: white)[#label~#number] + ] + ], + box( + width: arrow-width, + height: tag-height, + )[ + #polygon( + fill: fill-color, + (0pt, 0pt), + (arrow-width, tag-height / 2), + (0pt, tag-height), + ) + ] + ) +} + +// Style: rect - Number in a compact outlined rectangle (no label) +#let badge-rect(label, number, font-size, color, is-solution, metrics: (:)) = { + let g = badge-geom(metrics, pad-x: 6pt, pad-y: 4pt, radius: 0pt) + let fill-color = if is-solution { color.lighten(85%) } else { white } + box( + height: font-size + 2 * g.y, + stroke: 0.8pt + color, + fill: fill-color, + radius: g.radius, + inset: (x: g.x, y: 0pt), + align(horizon, text(weight: "bold", size: font-size, fill: color)[#number]), + ) +} + +// Style: filled-rect - Number in a compact filled rectangle +#let badge-filled-rect(label, number, font-size, color, is-solution, metrics: (:)) = { + let g = badge-geom(metrics, pad-x: 6pt, pad-y: 4pt, radius: 2pt) + box( + height: font-size + 2 * g.y, + fill: color, + radius: g.radius, + inset: (x: g.x, y: 0pt), + align(horizon, text(weight: "bold", size: font-size, fill: white)[#number]), + ) +} + +// Attach marker icons (optional star, difficulty, link arrow...) to the left +// of any badge shape +#let badge-with-marker(badge, marker) = { + if marker == none { return badge } + box(grid( + columns: (auto, auto), + column-gutter: 3pt, + align: (center + horizon, center + horizon), + box(height: 0.75em, align(center + horizon, marker)), + badge, + )) +} + +// Get badge based on style name (for badge-based styles only). +// `style` may also be a function (label, number, font-size, color, +// is-solution) => content for fully custom badges. +#let get-badge(style, label, number, font-size, color, is-solution, label-marker: none, metrics: (:)) = { + if type(style) == function { + // Custom badge functions keep the documented 5-argument contract: the + // geometry settings are the built-in shapes' business, not theirs + badge-with-marker(style(label, number, font-size, color, is-solution), label-marker) + } else if style == "circled" { + badge-with-marker(badge-circled(label, number, font-size, color, is-solution, metrics: metrics), label-marker) + } else if style == "filled-circle" { + badge-with-marker(badge-filled-circle(label, number, font-size, color, is-solution, metrics: metrics), label-marker) + } else if style == "rect" { + badge-with-marker(badge-rect(label, number, font-size, color, is-solution, metrics: metrics), label-marker) + } else if style == "filled-rect" { + badge-with-marker(badge-filled-rect(label, number, font-size, color, is-solution, metrics: metrics), label-marker) + } else if style == "pill" { + badge-with-marker(badge-pill(label, number, font-size, color, is-solution, metrics: metrics), label-marker) + } else if style == "tag" { + badge-with-marker(badge-tag(label, number, font-size, color, is-solution, metrics: metrics), label-marker) + } else { + // Default: box + badge-box(label, number, font-size, color, is-solution, label-marker: label-marker, metrics: metrics) + } +} + +// Compute default margin based on badge style and longest label with 3-digit +// number. The badge is measured as it will actually be drawn (same shape, same +// `badge-scale` / padding settings), so a rescaled badge moves the margin with it. +#let calc-default-margin(font-size: 12pt, style: "box", labels: auto, metrics: (:)) = { + let labels = if labels == auto { + ("Exercise", "Solution", "Correction", "Exercice", "Corrigé") + } else { + labels + } + + let max-width = 0pt + for label in labels { + let width = measure(get-badge(style, label, 100, font-size, black, false, metrics: metrics)).width + if width > max-width { max-width = width } + } + max-width + 16pt +} + +// Check if style is a "full-width" style (wraps content instead of badge+content grid) +#let is-fullwidth-style(style) = { + type(style) == str and style in ("margin", "border-accent", "underline", "rounded-box", "header-card") +} + +// ============================================================================= +// Full-Width Styles (wrap entire content) +// ============================================================================= + +// Style: margin - Side label with rule in the margin. +// For the solution variant the QR (if any) is already wrapped into the body. +// +// The side label costs `label-width` on every line of the statement, which a +// two-column measure cannot afford: below `fold-below` (and whenever the +// document asks for badge-position "above") the label folds onto a header line +// under the rule instead, and the statement keeps the full width. `fold` is +// none for the automatic decision, or true/false to force it. +#let style-margin( + label, number, body, font-size, color, is-solution, + qr: none, + label-width: 3.35cm, + gutter: 0.55cm, + fold-below: auto, + fold: none, +) = { + if is-solution { + block( + width: 100%, + inset: (x: 8pt, y: 6pt), + stroke: 0.55pt + color, + radius: 2pt, + breakable: true, + )[ + #text(size: font-size, weight: "bold", fill: color)[#label~#number] + #v(0.4em) + #text(size: 9pt)[#body] + ] + } else { + let side-label = text(size: font-size, weight: "bold", fill: color)[#label~#number:] + let folded = { + // Header line: the rule runs the whole measure, the label sits under it + // on the right, the statement keeps the full width below + block(width: 100%, breakable: false, { + line(length: 100%, stroke: 0.45pt + color) + v(-0.2em) + align(right, side-label) + if qr != none { v(0.45em); align(right, qr) } + }) + block(above: 0.35em, width: 100%, body) + } + let sided = grid( + columns: (label-width, 1fr), + column-gutter: gutter, + align: top, + [ + #line(length: 100%, stroke: 0.45pt + color) + #v(-0.2em) + #align(right)[#side-label] + #if qr != none { + v(0.45em) + align(right, qr) + } + ], + [ + #line(length: 0pt, stroke: 0.45pt + white) + #v(-0.2em) + #body + ], + ) + if fold == true { + folded + } else if fold == false { + sided + } else { + // Automatic: fold as soon as the statement column would be narrower than + // twice the label column + let threshold = if fold-below == auto { 3 * label-width + gutter } else { fold-below } + layout(size => if size.width < threshold { folded } else { sided }) + } + } +} + +// Keep a badge header line with what follows it: at a column or page break the +// header must never stay behind on its own. `block(sticky: true)` does exactly +// that from Typst 0.13 on; on older compilers it silently degrades to a plain +// block rather than raising the package's minimum compiler version. +#let sticky-block(above: 0pt, below: 0pt, body, ..args) = { + if sys.version >= version(0, 13, 0) { + block(above: above, below: below, sticky: true, body, ..args) + } else { + block(above: above, below: below, body, ..args) + } +} + +// Header line followed by the body at an exact distance (header-body-gap): +// both sit in blocks of their own so the paragraph spacing does not add up +// with the requested gap +#let _header-then-body(header, body, gap) = { + sticky-block(width: 100%, breakable: false, header) + block(above: gap, below: 0pt, width: 100%, breakable: true, body) +} + +// Style: border-accent - Left vertical bar with inline header +#let style-border-accent(label, number, body, font-size, color, is-solution, gaps: (:)) = { + let bar-color = color + let header = text(weight: "bold", size: font-size, fill: bar-color)[#label~#number] + let body-gap = gaps.at("body", default: auto) + block( + stroke: (left: 3pt + bar-color), + inset: (left: 12pt, y: 8pt), + width: 100%, + )[ + #if body-gap == auto [ + #context { + set text(size: calc.max(font-size, text.size)) + context sticky-block(below: par.spacing.to-absolute(), header) + } + #v(0.3em) + #body + ] else { + _header-then-body(header, body, body-gap) + } + ] +} + +// Style: underline - Bold header with underline +#let style-underline(label, number, body, font-size, color, is-solution, gaps: (:)) = context { + let line-color = color + let rule-gap = gaps.at("rule", default: auto) + let body-gap = gaps.at("body", default: auto) + let header = text(weight: "bold", size: font-size + 1pt, fill: line-color)[#label~#number] + let rule = line(length: 100%, stroke: 0.8pt + line-color) + // Legacy default par.spacing was resolved in the header's font size, then + // v(-0.3em) in the surrounding size. Freeze that geometry independently of + // the document's paragraph spacing. The horizontal rule has zero flow height + // (its 0.8pt stroke extends 0.4pt on each side of its centre). + let rule-gap = if rule-gap == auto { 1.2 * calc.max(font-size + 1pt, text.size) - 0.3em } else { rule-gap } + let body-gap = if body-gap == auto { 1.7em } else { body-gap } + block(width: 100%, { + sticky-block(width: 100%, breakable: false, { + block(spacing: 0pt, width: 100%, header) + block(above: rule-gap, below: 0pt, width: 100%, rule) + }) + block(above: body-gap, below: 0pt, width: 100%, breakable: true, body) + }) +} + +// Style: rounded-box - Clean rounded border around entire exercise +#let style-rounded-box(label, number, body, font-size, color, is-solution, gaps: (:)) = { + let border-color = color + let header = text(weight: "bold", size: font-size, fill: border-color)[#label~#number] + let body-gap = gaps.at("body", default: auto) + block( + width: 100%, + stroke: 1.2pt + border-color, + radius: 10pt, + inset: 12pt, + )[ + #if body-gap == auto [ + #context { + set text(size: calc.max(font-size, text.size)) + context sticky-block(below: par.spacing.to-absolute(), header) + } + #v(2pt) + #body + ] else { + _header-then-body(header, body, body-gap) + } + ] +} + +// Style: header-card - Rounded box with colored header strip +#let style-header-card(label, number, body, font-size, color, is-solution, gaps: (:)) = { + let body-gap = gaps.at("body", default: auto) + let header-color = color + block( + width: 100%, + stroke: 1pt + header-color, + radius: 8pt, + clip: true, + )[ + #sticky-block( + width: 100%, + breakable: false, + below: auto, + fill: header-color, + inset: (x: 12pt, y: 6pt), + )[ + #text(weight: "bold", size: font-size, fill: white)[#label~#number] + ] + #block( + width: 100%, + inset: (x: 12pt, top: if body-gap == auto { 8pt } else { body-gap }, bottom: 12pt), + above: 0pt, + )[#body] + ] +} + +// Wrap exercise content around a QR code at the top right. +// columns (1fr, auto) keep the QR flush right even when the content is short. +#let qr-wrap-body(qr, body) = _wrap-content( + box(inset: (left: 10pt, bottom: 6pt), qr), + body, + align: top + right, + column-gutter: 0pt, + columns: (1fr, auto), +) + +// Shared state key read by taskize (>= 0.2.8): a top-right zone that the next +// #tasks call flows around (first rows narrowed, rest full width). This is the +// only coupling between the two packages — the state key, not an import. +#let _taskize-wrap-zone = state("taskize-wrap-zone", none) + +// qr-position: "tasks" — overlay the QR at the top right (zero flow height) +// and publish the zone for the body's #tasks block. wrap-it can only flow +// plain text around the QR; a taskize grid is one opaque block to it, so the +// whole grid used to land below a reserved QR-height row. Here taskize itself +// splits its rows around the zone instead. Bodies without a #tasks block do +// not avoid the overlay — "tasks" is opt-in for taskize-based content. +// True when a body holds no block-level content at all, i.e. no #tasks call: +// then the whole of it has to keep clear of the overlay. +#let _is-all-inline(body) = { + if type(body) != content { return true } + let f = repr(body.func()) + if f == "styled" { return _is-all-inline(body.child) } + if f == "sequence" { return body.children.all(_is-all-inline) } + f in ( + "text", "space", "smartquote", "linebreak", "h", "parbreak", + "strong", "emph", "sub", "super", "underline", "overline", "strike", + "highlight", "link", "ref", "equation", + ) +} + +// Split a body into the run of inline content that opens it and the rest. +// An exercise usually reads "instruction sentence, then #tasks": the sentence +// is inline and ends at the paragraph break, the tasks block follows. +#let _split-leading-text(body) = { + if type(body) != content { return (none, body) } + // A body carrying `set` rules arrives wrapped in a styled node; split what + // is inside and put the styles back on both halves. + if repr(body.func()) == "styled" { + let (h, t) = _split-leading-text(body.child) + if h == none { return (none, body) } + let f = body.func() + return (f(h, body.styles), f(t, body.styles)) + } + if repr(body.func()) != "sequence" { + return (none, body) + } + let inline-funcs = ( + "text", "space", "smartquote", "linebreak", "h", + "strong", "emph", "sub", "super", "underline", "overline", "strike", + "highlight", "link", "ref", "styled", + ) + let head = () + let rest = () + let in-head = true + for child in body.children { + if not in-head { rest.push(child); continue } + let f = repr(child.func()) + if f == "parbreak" { in-head = false; continue } + let inline = f in inline-funcs or (f == "equation" and not child.at("block", default: true)) + if inline { head.push(child) } else { in-head = false; rest.push(child) } + } + let has-ink = head.any(c => { + let f = repr(c.func()) + if f in ("space", "h") { false } else if f == "text" { c.text.trim() != "" } else { true } + }) + if not has-ink or rest.len() == 0 { return (none, body) } + (head.join(), rest.join()) +} + +// qr-position: "tasks" — the QR is an overlay of zero flow height, and the +// zone it occupies is published for the body's #tasks call to flow around. +// +// Text placed before that #tasks call is not part of the grid taskize lays +// out, so it would run straight under the overlay. It is therefore set at a +// reduced width for as long as the overlay lasts, and only the height the +// overlay still occupies below it is published as the wrap zone. In a wide +// single column the sentence is one line and the tasks grid gets nearly the +// whole zone; in a narrow column it takes two or three lines and the zone +// shrinks accordingly, which is exactly the case that used to collide. +#let qr-overlay-body(qr, body) = context { + let padded = box(inset: (left: 10pt, bottom: 6pt), qr) + let size = measure(padded) + place(top + right, padded) + + let (lead, rest) = _split-leading-text(body) + // A body that is all text (no tasks call) is entirely "lead": it has to keep + // clear of the overlay on its own. + let (lead, rest) = if lead == none and rest != none and _is-all-inline(body) { + (body, none) + } else { + (lead, rest) + } + + if lead == none { + _taskize-wrap-zone.update((width: size.width, height: size.height)) + body + _taskize-wrap-zone.update(none) + } else { + layout(available => { + let narrow-width = available.width - size.width + let lead-height = measure(block(width: narrow-width, lead)).height + if lead-height >= size.height { + // The text runs past the overlay: let wrap-it flow it around an + // invisible stand-in for the code, so it returns to the full width + // once it is clear of it, instead of staying narrow to the end. + _wrap-content( + box(width: size.width, height: size.height), + lead, + align: top + right, + column-gutter: 0pt, + columns: (1fr, auto), + ) + if rest != none { rest } + } else { + // The text fits beside the code; the height the code still occupies + // below it is published for the tasks grid to flow around. + block(width: narrow-width, lead) + if rest != none { + _taskize-wrap-zone.update((width: size.width, height: size.height - lead-height)) + rest + _taskize-wrap-zone.update(none) + } + } + }) + } +} + +// Attach a top-right floater (QR code, page reference) to a body, honoring +// qr-position: "tasks" (taskize flows around it) vs the wrap-it default. +#let qr-attach-body(qr, body, cfg) = { + if qr == none { body } else if cfg.at("qr-position", default: "auto") == "tasks" { + qr-overlay-body(qr, body) + } else { + qr-wrap-body(qr, body) + } +} + +// Get full-width style block +// For the "margin" style the QR code goes below the side label; for the other +// full-width styles the exercise content wraps around the QR at the top right. +#let get-fullwidth-style( + style, label, number, body, font-size, color, is-solution, + qr: none, + margin-label-width: 3.35cm, + margin-label-gutter: 0.55cm, + margin-fold-below: auto, + margin-fold: none, + gaps: (:), // (rule: header-rule-gap, body: header-body-gap) +) = { + if style == "margin" { + style-margin( + label, number, body, font-size, color, is-solution, + qr: qr, + label-width: margin-label-width, + gutter: margin-label-gutter, + fold-below: margin-fold-below, + fold: margin-fold, + ) + } else if qr != none { + get-fullwidth-style(style, label, number, qr-wrap-body(qr, body), font-size, color, is-solution, gaps: gaps) + } else if style == "border-accent" { + style-border-accent(label, number, body, font-size, color, is-solution, gaps: gaps) + } else if style == "underline" { + style-underline(label, number, body, font-size, color, is-solution, gaps: gaps) + } else if style == "rounded-box" { + style-rounded-box(label, number, body, font-size, color, is-solution, gaps: gaps) + } else if style == "header-card" { + style-header-card(label, number, body, font-size, color, is-solution, gaps: gaps) + } else { + // Fallback - should not happen + body + } +} + +// Compute margin for badge layout (fits "Correction 100") +#let compute-margin(font-size: 11pt) = { + let test-badge = box( + stroke: 0.8pt, + inset: (x: 4pt, y: 3pt), + text(weight: "bold", size: font-size)[Correction 100], + ) + measure(test-badge).width + 12pt +} + +// Layout function for custom badge styles +#let exo-layout(badge, body, margin: auto) = context { + let m = if margin == auto { compute-margin() } else { margin } + grid( + columns: (m, 1fr), + column-gutter: 8pt, + align(right + top)[#badge], + body, + ) +} + +// Resolve the effective margin position from the config (must be called +// inside context: measures the configured labels) +#let calc-margin-pos(cfg) = { + if cfg.margin-position == auto { + // Let the widest badge use the label-extra space in the page margin + // instead of indenting all content by its full width + let needed = calc-default-margin( + font-size: cfg.label-font-size, + style: cfg.badge-style, + labels: (cfg.exercise-label, cfg.solution-label, cfg.correction-label), + metrics: badge-metrics(cfg), + ) + calc.max(needed - cfg.label-extra, 0pt) + } else { + cfg.margin-position + } +} + +// Competency tag styling +#let competency-tag(comp) = { + box( + fill: rgb("#e3f2fd"), + stroke: 0.5pt + rgb("#1976d2"), + radius: 3pt, + inset: (x: 4pt, y: 2pt), + text(size: 8pt, fill: rgb("#1565c0"))[#comp] + ) +} + +#let exo-box( + label: "Exercice", + number: 1, + body, + box-type: "exercise", // "exercise", "solution", or "correction" + exercise-id: none, // For UID display + show-id: false, // Whether to show the UID + competencies: (), // List of competencies + show-competencies: false, // Whether to show competencies + points: none, // Points for exam mode + points-label: "pts", // Label for points (e.g., "pts", "points") + label-marker: none, // Optional marker displayed before the badge label + badge-sub: none, // Optional marker displayed right below the badge (e.g., difficulty) + margin-content: none, // Optional content below the badge (e.g., QR code, remarks) + qr: none, // QR code: URL string (rendered with tiaoma) or content + badge-color: auto, // Override the badge color (e.g., difficulty color); auto = config + header-right: none, // Content pinned to the right of the badge line (badge-position: + // "above" only — the page reference, which would otherwise have to + // be wrapped into the statement itself) + badge-position: auto, // Override the configured badge position for this box only + // (auto = follow the configuration). Passed as an argument rather + // than set through exo-setup so that a caller which already read + // the config — the deferred correction section — does not write + // back into it, which would close a cycle in the introspection graph + title: none, // Optional exercise title ("Exercise 1 – Pythagorean theorem") + ref-data: none, // Captured by display callers before any body measurement +) = context { + let cfg = exo-config.get() + let number = if box-type == "exercise" and exercise-id != none { + let info = if ref-data != none { ref-data } else { + _exo-reference-data(exercise-id, number, cfg, none) + } + [#_exo-reference-marker(info)#number] + } else { number } + + // Title: inline in the header of the full-width styles (after + // title-separator, in the header's own weight), or as a bold line next to + // the badge for the badge styles + let title-fmt = cfg.at("title-format", default: auto) + let title-inline = if title != none { + if title-fmt == auto { title } else { title-fmt(title) } + } + let title-strong = if title != none { + if title-fmt == auto { strong(title) } else { title-fmt(title) } + } + let header-body-gap = cfg.at("header-body-gap", default: auto) + + // Determine the color based on box type + let actual-color = if box-type == "solution" { + cfg.solution-color + } else if box-type == "correction" { + cfg.correction-color + } else if badge-color != auto { + badge-color + } else { + cfg.badge-color + } + + // Is this a solution or correction (for styling purposes)? + let is-solution = box-type == "solution" or box-type == "correction" + + // Determine spacing based on box type + let (space-above, space-below) = if box-type == "solution" { + (cfg.solution-above, cfg.solution-below) + } else if box-type == "correction" { + (cfg.correction-above, cfg.correction-below) + } else { + (cfg.exercise-above, cfg.exercise-below) + } + + // Competencies display + let comp-block = if show-competencies and competencies.len() > 0 { + v(4pt) + for comp in competencies { + competency-tag(comp) + h(4pt) + } + } + + // ID display + let id-display = if show-id and exercise-id != none { + text(size: 7pt, fill: rgb("#666666"), style: "italic")[#exercise-id] + } + + // Full body with competencies + let full-body = { + set par(first-line-indent: 0cm) + body + if show-competencies and competencies.len() > 0 { comp-block } + if show-id and exercise-id != none { + v(4pt) + id-display + } + } + + let qr-pos = cfg.at("qr-position", default: "auto") + + let badge-pos = if badge-position == auto { + cfg.at("badge-position", default: "margin") + } else { + badge-position + } + + // Check if this is a full-width style + if is-fullwidth-style(cfg.badge-style) { + // Use full-width layout (style wraps the content) + // Full-width styles have no label column: below-badge markers join the + // inline ones + let label-marker = if badge-sub == none { label-marker } else if label-marker == none { + badge-sub + } else { + label-marker + h(2.5pt) + badge-sub + } + // Markers (optional star, difficulty icon...) prefix the header label; + // header-card draws its label on a filled strip, so the marker sits on a + // small white chip to stay visible + let label = if label-marker == none { label } else { + let marker = box(height: 0.75em, align(center + horizon, label-marker)) + if cfg.badge-style == "header-card" { + marker = box(fill: white, radius: 2pt, inset: (x: 2.5pt, y: 1.5pt), + box(height: 0.65em, align(center + horizon, label-marker))) + } + [#marker~#label] + } + // "margin" places the QR in its side-label column (unless qr-position + // is "wrap", or for its label-less solution variant); the others get it + // at full qr-size since the content area is wide + let qr-block = none + if cfg.badge-style == "margin" and qr-pos != "wrap" and not is-solution { + qr-block = make-exo-qr(qr, cfg, max-width: cfg.at("margin-label-width", default: 3.35cm)) + } else { + full-body = qr-attach-body(make-exo-qr(qr, cfg), full-body, cfg) + } + // The page reference (link-style "page") gets a right-aligned line of its + // own above the statement: the full-width styles have no badge line to pin + // it to, and wrapping the statement around it is what used to destabilise + // the layout (see attach-page-ref) + if header-right != none { + full-body = { + block(width: 100%, spacing: 0pt, align(right, header-right)) + v(2pt, weak: true) + full-body + } + } + // Title: the side label of "margin" is too narrow for it, so it opens the + // statement there; the other styles carry it in their header line + let number = number + if title != none { + if cfg.badge-style == "margin" and not is-solution { + full-body = { + block(spacing: 0pt, below: 0.6em, title-strong) + full-body + } + } else { + number = [#number#cfg.at("title-separator", default: [ -- ])#title-inline] + } + } + + block( + above: space-above, + below: space-below, + width: 100%, + breakable: true, + )[ + #get-fullwidth-style( + cfg.badge-style, + label, + number, + full-body, + cfg.label-font-size, + actual-color, + is-solution, + qr: qr-block, + margin-label-width: cfg.at("margin-label-width", default: 3.35cm), + margin-label-gutter: cfg.at("margin-label-gutter", default: 0.55cm), + margin-fold-below: cfg.at("margin-fold-below", default: auto), + // badge-position "above" is a document-wide statement that the badge + // must not cost column width: the side label folds with it + margin-fold: if badge-pos == "above" { true } else { none }, + gaps: ( + rule: if cfg.at("underline-gap", default: auto) != auto { + cfg.underline-gap + } else { cfg.at("header-rule-gap", default: auto) }, + body: if cfg.badge-style == "underline" and cfg.at("underline-below", default: auto) != auto { + cfg.underline-below + } else { header-body-gap }, + ), + ) + ] + } else { + // Use badge + grid layout + let badge = get-badge( + cfg.badge-style, + label, + number, + cfg.label-font-size, + actual-color, + is-solution, + label-marker: label-marker, + metrics: badge-metrics(cfg), + ) + + // Badge with optional points displayed inline after the box + let badge-with-points = if points != none { + box[#badge#h(6pt)#text(size: 9pt, fill: rgb("#555"), style: "italic")[(#points #points-label)]] + } else { + badge + } + + // ID display below badge (for badge styles) + let id-block = if show-id and exercise-id != none { + linebreak() + text(size: 7pt, fill: rgb("#666666"), style: "italic")[#exercise-id] + } + + if badge-pos == "above" { + // Badge alone on a header line, statement at full width underneath. The + // left column costs its width on *every* line of the exercise, which is + // expensive in a two-column layout and worse again once the statement + // holds an enumeration (its own indent stacks on top). Here the badge + // costs one line, once. + let header-left = { + set text(hyphenate: false) + box[#badge-with-points] + if badge-sub != none { h(4pt); badge-sub } + if title != none { h(6pt); title-strong } + if show-id and exercise-id != none { id-block } + } + // The QR keeps its own line under the header unless it was asked to be + // wrapped by the text, in which case the body already carries it + let stacked-body = body + let qr-block = none + if qr-pos == "wrap" or qr-pos == "tasks" { + stacked-body = qr-attach-body(make-exo-qr(qr, cfg), body, cfg) + } else { + qr-block = make-exo-qr(qr, cfg) + } + return block( + above: space-above, + below: space-below, + width: 100%, + breakable: true, + )[ + #sticky-block(if header-right == none { + header-left + } else { + grid( + columns: (auto, 1fr), + column-gutter: 8pt, + align: (left + horizon, right + horizon), + header-left, + header-right, + ) + }) + #if qr-block != none { sticky-block(above: 4pt, qr-block) } + #if margin-content != none { sticky-block(above: 4pt, margin-content) } + #block(above: if header-body-gap == auto { 4pt } else { header-body-gap }, width: 100%, breakable: true)[ + #stacked-body + #if show-competencies and competencies.len() > 0 { comp-block } + ] + ] + } + + // Margin position and label space (like environments) + let margin-pos = calc-margin-pos(cfg) + let label-extra = cfg.label-extra + let gap = 6pt + + // QR code below the badge, capped to the label column width so it adapts + // when the user reduces margin-position; with qr-position: "wrap" it goes + // into the content column instead, wrapped by the exercise text + let qr-block = none + let content-body = body + if qr-pos == "wrap" or qr-pos == "tasks" { + content-body = qr-attach-body(make-exo-qr(qr, cfg), body, cfg) + } else { + qr-block = make-exo-qr(qr, cfg, max-width: margin-pos + label-extra) + } + + // Never let the label column content overflow: the column is `auto`, so it + // takes the width of whatever the label holds (badge, difficulty marker, + // QR code), and a zero-height strut of the configured margin keeps it from + // shrinking below the width every exercise, solution and correction lines + // up on. + // + // The obvious alternative — measuring the badge here and passing an + // explicit column width — is what this replaces: measuring the real badge + // feeds a layout-dependent value back into the layout, and in a document + // that both mixes badge styles and resolves page references ("Solution + // p. 30") the two never agreed, so it never converged ("a measured element + // did not stabilize"). The strut states the same constraint declaratively, + // with nothing to converge on. + let label-col-min = margin-pos + label-extra + + // Build the label column content + let label-column = { + set text(hyphenate: false) + block(width: label-col-min, height: 0pt, spacing: 0pt) + align(right)[ + #box[#badge-with-points] + #if badge-sub != none { + block(above: 3pt, badge-sub) + } + #if show-id and exercise-id != none { id-block } + #if qr-block != none { + // Block keeps the QR on its own line, close below the badge + block(above: 4pt, qr-block) + } + #if margin-content != none { + v(4pt) + margin-content + } + ] + } + + // Use grid - shifted left so labels can extend into page margin + block( + above: space-above, + below: space-below, + width: 100% + label-extra, + breakable: true, + inset: (left: -label-extra, right: label-extra), // Add right margin to compensate + )[ + #grid( + columns: (auto, 1fr), + column-gutter: gap, + align: (right + top, left + top), + // Label column - has space for label, right-aligned + label-column, + // Content column + block( + width: 100%, + inset: (left: 0pt, y: 0pt), + breakable: true, + )[ + #set par(first-line-indent: 0cm) + #v(3pt) // Align with badge text + #if title != none { + block(spacing: 0pt, below: if header-body-gap == auto { 0.6em } else { header-body-gap }, title-strong) + } + #content-body + #if show-competencies and competencies.len() > 0 { comp-block } + ], + ) + ] + } +} + +#let exo-solution-box(number: 1, body, exercise-id: none, show-id: false, qr: none, label-marker: none, badge-position: auto, title: none) = context { + let cfg = exo-config.get() + exo-box( + label: cfg.solution-label, + number: number, + body, + box-type: "solution", + exercise-id: exercise-id, + show-id: show-id, + qr: qr, + label-marker: label-marker, + badge-position: badge-position, + title: if cfg.at("title-in-solutions", default: false) { title }, + ) +} + +#let exo-correction-box(number: 1, body, exercise-id: none, show-id: false, qr: none, label-marker: none, badge-position: auto, title: none) = context { + let cfg = exo-config.get() + exo-box( + label: cfg.correction-label, + number: number, + body, + box-type: "correction", + exercise-id: exercise-id, + show-id: show-id, + qr: qr, + label-marker: label-marker, + badge-position: badge-position, + title: if cfg.at("title-in-solutions", default: false) { title }, + ) +} + +// ============================================================================= +// Helper Functions for Content Display +// ============================================================================= + +// Check if content is empty (none or empty content block) +#let is-empty-content(content) = { + content == none or repr(content) == "[]" +} + +// Apply placeholder if content is empty (only in draft mode) +#let apply-placeholder(content, placeholder, draft-mode) = { + if is-empty-content(content) { + if draft-mode { + placeholder + } else { + [ ] // Empty space to maintain box/counter + } + } else { + content + } +} + +// Determine what content to show based on corrDisplay mode and exercise flags +// Returns: array of (type: "solution" or "correction", content: content, qr: ...) +// - may contain 0, 1, or 2 items +#let determine-content-to-show(solution, correction, cfg, exercise-flags, qr-sol: none, qr-corr: none) = { + let solInCorr = exercise-flags.at("solInCorr", default: false) + let showCorr = exercise-flags.at("showCorr", default: false) + let items = () + + if cfg.corrDisplay == "solution" { + // Only show solution + if solution != none { + let sol-content = apply-placeholder(solution, cfg.solution-placeholder, cfg.draft-mode) + items.push((type: "solution", content: sol-content)) + } + } else if cfg.corrDisplay == "correction" { + // Show correction, and also solution if solInCorr is false + if correction != none { + let corr-content = apply-placeholder(correction, cfg.correction-placeholder, cfg.draft-mode) + items.push((type: "correction", content: corr-content)) + } + // If solution is NOT already in correction, also show solution + if not solInCorr and solution != none { + let sol-content = apply-placeholder(solution, cfg.solution-placeholder, cfg.draft-mode) + items.push((type: "solution", content: sol-content)) + } + } else { + // "mixed" mode: default to solution, but use correction for exercises with showCorr: true + if showCorr and correction != none { + let corr-content = apply-placeholder(correction, cfg.correction-placeholder, cfg.draft-mode) + items.push((type: "correction", content: corr-content)) + // Also show solution if not already in correction + if not solInCorr and solution != none { + let sol-content = apply-placeholder(solution, cfg.solution-placeholder, cfg.draft-mode) + items.push((type: "solution", content: sol-content)) + } + } else if solution != none { + let sol-content = apply-placeholder(solution, cfg.solution-placeholder, cfg.draft-mode) + items.push((type: "solution", content: sol-content)) + } else if correction != none { + // Fallback to correction if no solution + let corr-content = apply-placeholder(correction, cfg.correction-placeholder, cfg.draft-mode) + items.push((type: "correction", content: corr-content)) + } + } + + // Attach the matching QR code to each item + items.map(item => { + item.insert("qr", if item.type == "solution" { qr-sol } else { qr-corr }) + item + }) +} + +// Invisible link target (defined at module level: inside exo() the name +// `metadata` is shadowed by the exercise metadata dict) +#let make-link-target(name) = [#metadata(none)#label(name)] + +// Epigraph-style inline solution: a short rule + the content, right under +// the statement (no badge box). A small margin label (inline-label, defaults +// to the solution label) makes clear this is the solution. The layout mirrors +// the badge grid so the rule aligns with the exercise content column. +#let inline-solution-block(content, cfg) = context { + let small-label = { + let l = cfg.at("inline-label", default: auto) + if l == auto { + text(size: 8pt, fill: cfg.solution-color, style: "italic", cfg.solution-label) + } else { + l + } + } + let body = { + set par(first-line-indent: 0cm) + line(length: cfg.at("inline-rule-length", default: 3cm), stroke: 0.5pt + cfg.solution-color) + v(0.2em) + content + } + if is-fullwidth-style(cfg.badge-style) { + block( + above: cfg.solution-above, + below: cfg.solution-below, + width: 100%, + breakable: true, + )[ + #if small-label != none [#small-label#v(0.1em)] + #body + ] + } else { + let label-extra = cfg.label-extra + block( + above: cfg.solution-above, + below: cfg.solution-below, + width: 100% + label-extra, + breakable: true, + inset: (left: -label-extra, right: label-extra), + )[ + #grid( + columns: (calc-margin-pos(cfg) + label-extra, 1fr), + column-gutter: 6pt, + align: (right + top, left + top), + align(right, small-label), + body, + ) + ] + } +} + +// Render solution/correction items returned by determine-content-to-show. +// - loc: where these items are being placed ("after" enables the inline +// solution style) +// - link-base: when set (and link-solutions is on), the first item receives +// the "exb-corr-" link target and every item gets a back-link icon +// to the exercise +#let show-content-items(items, number, exercise-id, cfg, loc: none, link-base: none, title: none) = { + let first = true + for item in items { + let body = item.content + let marker = none + if link-base != none and cfg.at("link-solutions", default: false) { + if first { + body = [#make-link-target("exb-corr-" + link-base)#body] + } + let icon = cfg.at("backlink-icon", default: none) + if icon != none { + marker = link(label("exb-ex-" + link-base), icon) + } + } + first = false + if item.type == "solution" { + if loc == "after" and cfg.at("solution-style", default: auto) == "inline" { + inline-solution-block(body, cfg) + } else { + exo-solution-box( + number: number, + body, + exercise-id: exercise-id, + show-id: cfg.show-id, + qr: item.at("qr", default: none), + label-marker: marker, + title: title, + ) + } + } else { + exo-correction-box( + number: number, + body, + exercise-id: exercise-id, + show-id: cfg.show-id, + qr: item.at("qr", default: none), + label-marker: marker, + title: title, + ) + } + } +} + +// Where an item type goes: solutions follow sol-loc (auto = corr-loc), +// corrections follow corr-loc +#let resolve-item-loc(cfg, item-type) = { + if item-type == "solution" { + let s = cfg.at("sol-loc", default: auto) + if s == auto { cfg.corrLoc } else { s } + } else { + cfg.corrLoc + } +} + +// True if the exercise should carry a clickable link icon to its deferred +// solution/correction +#let has-deferred-items(items, cfg) = { + items.any(item => resolve-item-loc(cfg, item.type) != "after") +} + +// Textbook-style page reference ("Solution p. 30") shown at the top right of +// the statement, linking to the deferred solution/correction (link-style: "page") +// +// Must be called from inside a context block (all call sites already are). It +// deliberately does *not* open its own `context`: with badge-position "margin" +// the reference is handed to wrap-it, which measures it to reserve the wrap +// zone, and `measure` cannot resolve a nested `context` against the real +// document — it falls back to "the closest matching element", which changes +// from one layout pass to the next ("a measured element did not stabilize", +// and downstream "query for elements labelled <...> did not stabilize"). +// Resolving the query in the caller's context makes the measured content plain. +#let make-page-ref(cfg, link-base, items, badge-color) = { + let target = label("exb-corr-" + link-base) + let deferred = items.filter(item => resolve-item-loc(cfg, item.type) != "after") + let ref-label = if deferred.len() > 0 and deferred.first().type == "correction" { + cfg.correction-label + } else { + cfg.solution-label + } + let found = query(target) + let page-num = if found.len() > 0 { + counter(page).at(found.first().location()).first() + } + let fmt = cfg.at("page-ref-format", default: auto) + let render = num => if fmt == auto { + let color = cfg.at("page-ref-color", default: auto) + if color == auto { + color = if badge-color != auto { badge-color } else { cfg.badge-color } + } + text(fill: color, size: 0.92em)[#ref-label p.~#num] + } else { + fmt(ref-label, num) + } + // The reference sits in an `auto` grid column next to the statement, so its + // width feeds back into the layout: a page number that grows from 9 to 10 + // would narrow the text column, reflow the statement, shift the pages and + // change the number again ("page counter did not converge"). Reserve a size + // computed from an all-9s number of the same digit count (padded to 3), which + // is the widest number of that width and no longer varies from one layout + // pass to the next. + // + // The same holds for the reference's mere *presence*: on the first layout + // pass the correction has not been laid out yet, so the query finds nothing. + // Emitting no reference at all on that pass, and one on the next, changes the + // layout underneath the very page numbers being resolved, and a document that + // is close to a page boundary can flip between the two for ever. So the space + // is reserved either way, and only the text inside it appears once the target + // is known. + let digits = if page-num == none { 3 } else { calc.max(3, str(page-num).len()) } + let size = measure(render(int("9" * digits))) + // Returns the size alongside the content: with badge-position "margin" the + // caller must reserve the wrap zone with a placeholder of exactly these + // dimensions rather than with the reference itself (see attach-page-ref). + ( + width: size.width, + height: size.height, + body: if page-num == none { + box(width: size.width, height: size.height) + } else { + box(width: size.width, align(right, link(target, render(page-num)))) + }, + ) +} + +// Route the page reference to wherever it belongs for the current layout: +// with badge-position "above" the badge line has room for it on the right; +// otherwise it has to be wrapped into the top right of the statement itself. +// `ref` is the dict returned by make-page-ref, or none when there is nothing +// to link to. Returns the (possibly rewrapped) body and the header slot for +// exo-box. +#let attach-page-ref(cfg, ref, body, badge-pos: auto) = { + let badge-pos = if badge-pos == auto { + cfg.at("badge-position", default: "margin") + } else { badge-pos } + if ref == none { + (body: body, header-right: none) + } else if ( + badge-pos == "above" or is-fullwidth-style(cfg.at("badge-style", default: "box")) + ) { + // Goes in a grid cell (or, for the full-width styles, on a line of its own + // above the statement), which is laid out, never measured — the reference + // itself can be used as is. + // + // The full-width styles used to take the wrap-it branch below instead, and + // that is what stopped such documents from converging: wrap-it measures the + // *content* as well as the float, the content carries the exercise's own + // link target, and `measure` cannot resolve an introspection against the + // real document — it falls back to the closest matching element, which + // moved from one layout pass to the next. + (body: body, header-right: ref.body) + } else { + // wrap-it measures the float to size the wrap zone, and `measure` cannot + // resolve the link's label against the real document: it falls back to + // "the closest matching element", which differs from one layout pass to + // the next, so the measurement — and with it the whole document — never + // settles. Reserve the zone with an empty box of the reference's exact + // size instead (nothing introspective to resolve, stable by construction) + // and draw the reference itself out of flow on top, where it is never + // measured. The placeholder is right-aligned by qr-attach-body, so both + // land in the same spot. + let placeholder = box(width: ref.width, height: ref.height) + ( + body: block(width: 100%, spacing: 0pt, { + place(top + right, ref.body) + qr-attach-body(placeholder, body, cfg) + }), + header-right: none, + ) + } +} + +// Place solution/correction items per type: immediately after the exercise, +// after a pagebreak, or deferred to the pending queue (end-section / +// end-chapter, printed by exo-print-solutions) +#let place-content-items(items, number, exercise-id, cfg, link-base: none, title: none) = { + let after-items = () + let pagebreak-items = () + let deferred = () + for item in items { + let loc = resolve-item-loc(cfg, item.type) + if loc == "pagebreak" { + pagebreak-items.push(item) + } else if loc == "end-section" or loc == "end-chapter" { + deferred.push((item: item, loc: loc)) + } else { + after-items.push(item) + } + } + show-content-items(after-items, number, exercise-id, cfg, loc: "after", title: title) + let label-attached = false + if pagebreak-items.len() > 0 { + pagebreak(weak: true) + show-content-items(pagebreak-items, number, exercise-id, cfg, link-base: link-base, title: title) + label-attached = true + } + for (i, d) in deferred.enumerate() { + // The "exb-corr" link target must be unique: attach it to the first + // deferred item only if the pagebreak group didn't already carry it + let attach = link-base != none and not label-attached and i == 0 + exo-pending-solutions.update(pending => { + pending.push(( + number: number, + content: d.item.content, + type: d.item.type, + id: exercise-id, + qr: d.item.at("qr", default: none), + loc: d.loc, + link-base: link-base, + attach-label: attach, + title: title, + )) + pending + }) + } +} + +// ============================================================================= +// Main Exercise Function +// ============================================================================= + +// Worked example (exo(worked: true)): the solution/correction to show. The +// usual corr-display choice first; when it yields nothing (e.g. only a +// correction while corr-display is "solution"), whatever the exercise has. +#let _worked-items(items, solution, correction, cfg, qr-sol: none, qr-corr: none) = { + if items.len() > 0 { return items } + if correction != none { + ((type: "correction", content: correction, qr: qr-corr),) + } else if solution != none { + ((type: "solution", content: solution, qr: qr-sol),) + } else { + () + } +} + +// Configuration seen by a worked example: every item is placed "after" +#let _worked-cfg(cfg) = { + let cfg = cfg + cfg.corrLoc = "after" + cfg.sol-loc = "after" + cfg +} + +#let exo( + exercise: none, + solution: none, + correction: none, + id: auto, + title: none, // Optional title shown after "Exercise 1" + worked: false, // Worked example: always show the solution/correction + // right after the statement (even with display: "ex" + // or a deferred corr-loc) + margin-content: none, // Optional content below the badge (e.g., QR code, remarks) + qr: none, // QR code: URL string or content, placed per badge style + qr-sol: none, // QR code shown on the solution box + qr-corr: none, // QR code shown on the correction box + // Exercise-level display flags + sol-in-corr: false, // If true, correction already contains solution (use solution in "correction" mode) + show-corr: false, // If true, show correction in "mixed" mode + optional: false, // If true, show the optional marker before the exercise label + corr-given: false, // If true, show the corr-given marker (correction handed out) + // Metadata fields + topic: none, + level: none, + difficulty: none, // Difficulty level (key into difficulty-scale, e.g. 1-5) + authors: (), // Array of authors, e.g., ("Nathan", "Raph") + ..extra-metadata +) = { + // Step counters first (outside context) + exo-counter.step() + exo-display-counter.step() + + context { + let cfg = exo-config.get() + + // Get number after stepping + let num = exo-counter.get().first() + + // Generate or use provided ID + let exercise-id = if id == auto { + "exo-" + str(num) + } else { + id + } + + // Build metadata dictionary + let metadata = ( + topic: topic, + level: level, + difficulty: difficulty, + authors: authors, + optional: optional, + corr-given: corr-given, + ) + // Add extra metadata + for (key, value) in extra-metadata.named() { + metadata.insert(key, value) + } + + // Build exercise flags + let exercise-flags = ( + solInCorr: sol-in-corr, + showCorr: show-corr, + ) + + // Displayed number (possibly chapter-prefixed) and difficulty badge color + let disp-num = format-exo-number(cfg, num) + let ex-badge-color = get-exercise-badge-color(cfg, metadata) + + // Create exercise record + let exercise-record = ( + id: exercise-id, + number: num, + metadata: metadata, + exercise: exercise, + solution: solution, + correction: correction, + margin-content: margin-content, + qr: qr, + qr-sol: qr-sol, + qr-corr: qr-corr, + solInCorr: sol-in-corr, + showCorr: show-corr, + title: title, + worked: worked, + ) + + // Register exercise + exo-registry.update(reg => { + reg.push(exercise-record) + reg + }) + + // Display based on show mode + if cfg.display == "sol" { + // Only show solution/correction + let items-to-show = determine-content-to-show(solution, correction, cfg, exercise-flags, qr-sol: qr-sol, qr-corr: qr-corr) + if worked { items-to-show = _worked-items(items-to-show, solution, correction, cfg, qr-sol: qr-sol, qr-corr: qr-corr) } + show-content-items(items-to-show, disp-num, exercise-id, cfg, title: title) + } else if cfg.display == "ex" { + // Only show exercise + exo-box( + label: get-exercise-label(cfg, metadata), + number: disp-num, + exercise, + exercise-id: exercise-id, + ref-data: _exo-reference-data( + exercise-id, disp-num, cfg, metadata.at("topic", default: none), + group: exo-display-counter.get().first(), + ), + show-id: cfg.show-id, + label-marker: get-exercise-marker(cfg, metadata), + badge-sub: get-difficulty-badge-sub(cfg, metadata), + margin-content: margin-content, + qr: qr, + badge-color: ex-badge-color, + title: title, + ) + // Worked example: its solution shows even in the exercises-only version + if worked { + let items = determine-content-to-show(solution, correction, cfg, exercise-flags, qr-sol: qr-sol, qr-corr: qr-corr) + show-content-items( + _worked-items(items, solution, correction, cfg, qr-sol: qr-sol, qr-corr: qr-corr), + disp-num, exercise-id, cfg, loc: "after", title: title, + ) + } + } else { + // "both" mode - show exercise and solution/correction + let items-to-show = determine-content-to-show(solution, correction, cfg, exercise-flags, qr-sol: qr-sol, qr-corr: qr-corr) + // A worked example keeps its solution right under the statement, whatever + // corr-loc says (nothing deferred, so no link either) + let cfg = if worked { _worked-cfg(cfg) } else { cfg } + if worked { items-to-show = _worked-items(items-to-show, solution, correction, cfg, qr-sol: qr-sol, qr-corr: qr-corr) } + + // Exercise <-> correction links: only when something is actually deferred + let link-base = none + let link-marker = none + let header-right = none + let exercise-body = exercise + if cfg.at("link-solutions", default: false) and has-deferred-items(items-to-show, cfg) { + link-base = str(exo-display-counter.get().first()) + exercise-body = [#make-link-target("exb-ex-" + link-base)#exercise-body] + if cfg.at("link-style", default: "icon") == "page" { + let routed = attach-page-ref( + cfg, + make-page-ref(cfg, link-base, items-to-show, ex-badge-color), + exercise-body, + ) + exercise-body = routed.body + header-right = routed.header-right + } else { + let icon = cfg.at("link-icon", default: none) + if icon != none { + link-marker = link(label("exb-corr-" + link-base), icon) + } + } + } + + // Show exercise + exo-box( + label: get-exercise-label(cfg, metadata), + number: disp-num, + exercise-body, + exercise-id: exercise-id, + ref-data: _exo-reference-data( + exercise-id, disp-num, cfg, metadata.at("topic", default: none), + group: exo-display-counter.get().first(), + ), + show-id: cfg.show-id, + label-marker: get-exercise-marker(cfg, metadata, extra: link-marker), + badge-sub: get-difficulty-badge-sub(cfg, metadata), + margin-content: margin-content, + qr: qr, + badge-color: ex-badge-color, + header-right: header-right, + title: title, + ) + + // Handle solution/correction display (per-type location) + place-content-items(items-to-show, disp-num, exercise-id, cfg, link-base: link-base, title: title) + } + } // close context +} // close function + +// ============================================================================= +// Multi-column layouts +// ============================================================================= + +// Typst's default page margin when `page.margin` (or one of its sides) is auto. +#let _default-page-margin(w, h) = 2.5 / 21 * calc.min(w, h) + +// Resolve `page.margin` (auto, a length/ratio, or a dictionary) into four +// absolute lengths, so the column rules can be placed in the text area. +#let _resolve-page-margins(m, w, h) = { + let d = _default-page-margin(w, h) + let pick(v, base) = { + if v == auto or v == none { d } + else if type(v) == ratio { v * base } + else { v } + } + if type(m) == dictionary { + let rest = m.at("rest", default: auto) + ( + left: pick(m.at("left", default: m.at("x", default: rest)), w), + right: pick(m.at("right", default: m.at("x", default: rest)), w), + top: pick(m.at("top", default: m.at("y", default: rest)), h), + bottom: pick(m.at("bottom", default: m.at("y", default: rest)), h), + ) + } else { + (left: pick(m, w), right: pick(m, w), top: pick(m, h), bottom: pick(m, h)) + } +} + +// Vertical separators centred in each gutter of a `count`-column area of the +// given size, offset by (dx, dy). Placed, so they take no space. +#let _column-rules( + width: 0pt, + height: 0pt, + count: 2, + gutter: 4%, + rule: none, + rule-inset: 0pt, + dx: 0pt, + dy: 0pt, +) = { + if rule == none or count < 2 { return } + let g = if type(gutter) == ratio { gutter * width } else { gutter } + let colw = (width - g * (count - 1)) / count + for i in range(1, count) { + place( + top + left, + dx: dx + colw * i + g * (i - 0.5), + dy: dy + rule-inset, + line(angle: 90deg, length: height - 2 * rule-inset, stroke: rule), + ) + } +} + +// Scoped `badge-position` switch: exercises inside a narrow column are much +// more readable with the badge above the statement, but the change must not +// leak into the rest of the document. +#let _with-badge-position(pos, body) = { + if pos == none { return body } + let pos = if pos == auto { "above" } else { pos } + context { + let previous = exo-config.get().at("badge-position", default: "margin") + exo-setup(badge-position: pos) + body + exo-setup(badge-position: previous) + } +} + +// Whole document (or everything after it) in `count` columns, with an optional +// vertical rule between them: +// +// #show: exo-page-columns.with(count: 2, rule: 0.5pt + gray) +// +// Columns are page columns, so the text flows across pages normally and the +// rule is drawn on every page. +#let exo-page-columns( + count: 2, + gutter: 4%, + rule: none, // none, or a stroke for the separator, e.g. 0.5pt + gray + rule-inset: 0pt, // Shorten the rule by this much at the top and bottom + badge-position: auto, // auto = "above" (badges cost no column width), + // none = leave the current configuration alone + body, +) = { + let bg = context { + let w = page.width + let h = page.height + let m = _resolve-page-margins(page.margin, w, h) + _column-rules( + width: w - m.left - m.right, + height: h - m.top - m.bottom, + count: count, + gutter: gutter, + rule: rule, + rule-inset: rule-inset, + dx: m.left, + dy: m.top, + ) + } + set columns(gutter: gutter) + set page(columns: count, background: bg) + _with-badge-position(badge-position, body) +} + +// Split a body into the pieces a columned grid can distribute. Only a top-level +// sequence can be split — anything else (a single element, or a `styled` node +// wrapping the whole body, which cannot be re-applied to its pieces one by one) +// comes back whole, and the caller falls back to flowing `columns`. +// +// Zero-height companions (the spaces and paragraph breaks between two +// exercises, a `state` update, a bare label) are attached to the piece they +// follow so a column never opens on a stray blank line. +#let _split-into-items(body) = { + if type(body) != content { return (body,) } + // Elements that take no room of their own and belong with the piece that + // *follows* them: `exo()` emits two counter updates before its content, and + // cutting between them would renumber the exercises. Spaces and paragraph + // breaks join them so a column never opens on a stray blank line. + let invisible = ("space", "parbreak", "linebreak", "counter-update", + "state-update", "metadata", "styled-empty") + + // `#exo-columns(..)[ #for .. ]` hands over sequence(space, , space): + // everything worth splitting sits one level down. Descend for as long as a + // sequence holds a single sequence of its own — never through anything else, + // and in particular never through a `styled` node, whose styles could not be + // re-applied to the pieces one by one. + let node = body + let depth = 0 + while depth < 8 and repr(node.func()) == "sequence" { + let real = node.children.filter(c => repr(c.func()) not in invisible) + if real.len() == 1 and repr(real.first().func()) == "sequence" { + node = real.first() + depth += 1 + } else { + break + } + } + if repr(node.func()) != "sequence" { return (node,) } + + let items = () + let pending = () + for child in node.children { + if repr(child.func()) in invisible { + pending.push(child) + } else { + items.push(pending.join() + child) + pending = () + } + } + // Anything trailing has nothing to lead: keep it with the last piece + if pending.len() > 0 and items.len() > 0 { + items.last() = items.last() + pending.join() + } else if pending.len() > 0 { + items.push(pending.join()) + } + items +} + +// Lay `items` out in `count` columns with a vertical rule between them. +// +// A grid, unlike `columns`, draws the rule itself at the real height of the +// row, on every page the row breaks over -- so the rule stops where the content +// stops, and a row longer than a page simply continues on the next one. The +// price is that the items have to be handed to the columns up front instead of +// flowing from one into the next. +// +// They are split by count, not by measured height. Measuring would balance the +// columns better, but every item here is a `context` element (an exercise, a +// correction box), and `measure` cannot resolve an introspection against the +// real document -- it falls back to "the closest matching element", which moves +// between layout passes as soon as a document holds several similar boxes, and +// the document then never converges. So: an item much taller than its +// neighbours leaves its column longer than the others, and nothing wobbles. +#let _ruled-columns(count: 2, gutter: 4%, rule: none, items) = { + let per = calc.ceil(items.len() / count) + let cells = range(count).map(i => items.slice( + calc.min(i * per, items.len()), + calc.min((i + 1) * per, items.len()), + ).join()) + + // Half-gutter spacer columns so the rule lands in the middle of the gutter + // (a grid line sits on a column edge, never inside a gutter). + let g = gutter + let half = if type(g) == ratio { g / 2 } else { g / 2 } + let cols = () + let body = () + for i in range(count) { + if i > 0 { + cols.push(half) + cols.push(half) + body.push([]) + body.push([]) + } + cols.push(1fr) + body.push(cells.at(i)) + } + grid( + columns: cols, + align: top, + ..range(1, count).map(i => grid.vline(x: 3 * i - 1, stroke: rule)), + ..body, + ) +} + +// One columned block inside an otherwise single-column document, with an +// optional vertical rule between the columns: +// +// #exo-columns(count: 2, rule: 0.5pt + gray)[ ... exercises ... ] +// +// By default the block is `columns` inside a block whose height is estimated +// from the content: it balances well, but the estimate caps the block, so a +// body taller than the estimate is cut off — and a body longer than a page is +// lost outright. +// +// `balance: true` asks instead for the body to be split into its own pieces and +// laid out as a grid (see _ruled-columns), which breaks across pages like any +// other block and stops the rule at the content. It is opt-in because two ruled +// grids in one document — a split block plus corrections in ruled columns — +// together with `exo-auto-chapter` can leave the document short of converging. +// +// For a whole two-column document use `exo-page-columns`. +#let exo-columns( + count: 2, + gutter: 4%, + rule: none, // none, or a stroke for the separator, e.g. 0.5pt + gray + rule-inset: 0pt, // Shorten the rule by this much at the top and bottom + // (flowing path only; the grid stops at the content) + height: auto, // auto = balance the columns; or a fixed block height + slack: 3, // Extra lines added to the estimated column height + // (flowing path only) + balance: auto, // auto (default) = flow through `columns` in a block of + // estimated height; true = split the body between the + // columns, which breaks across pages and stops the rule at + // the content (needs a body that can be split, and a rule) + badge-position: auto, // auto = "above", none = leave the configuration alone + body, +) = { + // The body has to be split *before* the badge-position wrapper goes on: that + // wrapper is a single `context` element, so splitting after it would always + // yield one piece and silently fall back to the estimate. + let items = if balance == true and rule != none and height == auto { + _split-into-items(body) + } else { + () + } + if items.len() >= count { + return _with-badge-position( + badge-position, + _ruled-columns(count: count, gutter: gutter, rule: rule, items), + ) + } + + let body = _with-badge-position(badge-position, body) + layout(size => context { + let w = size.width + let g = if type(gutter) == ratio { gutter * w } else { gutter } + let colw = (w - g * (count - 1)) / count + let h = if height == auto { + // `columns` fills column after column, so an open block would put + // everything in the first column: give it the balanced height instead. + let natural = measure(block(width: colw, body)).height + let line-height = measure(block(width: colw, [X])).height + calc.min(natural / count + slack * line-height, size.height) + } else { + height + } + block(width: w, height: h, { + _column-rules( + width: w, + height: h, + count: count, + gutter: g, + rule: rule, + rule-inset: rule-inset, + ) + columns(count, gutter: g, body) + }) + }) +} + +#let _columned-corrections(count: 1, gutter: 4%, rule: none, items) = { + if count == none or count <= 1 { return items.join() } + if rule == none { + return columns(count, gutter: gutter, items.join()) + } + _ruled-columns(count: count, gutter: gutter, rule: rule, items) +} + +#let exo-print-solutions(title: auto, loc: none) = context { + let cfg = exo-config.get() + let pending = exo-pending-solutions.get() + + let to-print = () + let to-keep = () + for item in pending { + if loc == none or item.at("loc", default: none) == loc { + to-print.push(item) + } else { + to-keep.push(item) + } + } + + if to-print.len() > 0 { + let section-title = if title == auto { + // "Corrections" when only corrections are pending, "Solutions" otherwise + if to-print.all(item => item.at("type", default: "solution") == "correction") { + cfg.correction-label + "s" + } else { + cfg.solution-label + "s" + } + } else { + title + } + + v(1em) + text(weight: "bold", size: 12pt)[#section-title] + v(0.5em) + + // Narrow columns: the badge goes above the correction (it costs no column + // width there). Passed down to each box as an argument — see exo-box. + let col-count = cfg.at("corr-columns", default: 1) + let corr-pos = cfg.at("corr-columns-badge-position", default: auto) + if corr-pos == auto { + corr-pos = if col-count != none and col-count > 1 { "above" } else { auto } + } + + let boxes = () + for item in to-print { + let content = item.at("content", default: none) + let item-type = item.at("type", default: "solution") + let item-qr = item.at("qr", default: none) + + // Exercise <-> correction links: link target + back-link icon + let marker = none + let link-base = item.at("link-base", default: none) + if link-base != none and cfg.at("link-solutions", default: false) { + if item.at("attach-label", default: false) { + content = [#make-link-target("exb-corr-" + link-base)#content] + } + let icon = cfg.at("backlink-icon", default: none) + if icon != none { + marker = link(label("exb-ex-" + link-base), icon) + } + } + + if item-type == "solution" { + boxes.push(exo-solution-box( + number: item.number, + content, + exercise-id: item.at("id", default: none), + show-id: cfg.show-id, + qr: item-qr, + label-marker: marker, + badge-position: corr-pos, + title: item.at("title", default: none), + )) + } else { + boxes.push(exo-correction-box( + number: item.number, + content, + exercise-id: item.at("id", default: none), + show-id: cfg.show-id, + qr: item-qr, + label-marker: marker, + badge-position: corr-pos, + title: item.at("title", default: none), + )) + } + } + + _columned-corrections( + count: col-count, + gutter: cfg.at("corr-columns-gutter", default: 4%), + rule: cfg.at("corr-columns-rule", default: none), + boxes, + ) + } + + // Keep only the items deferred to another location. The new value must be a + // pure function of the previous one: passing `to-keep` would make a state + // update depend on a value obtained by introspection (the `.get()` above), + // which closes a cycle in the introspection graph and can stop the document + // from converging. The predicate below is the same filter, applied lazily. + exo-pending-solutions.update(pending => if loc == none { () } else { + pending.filter(item => item.at("loc", default: none) != loc) + }) +} + +#let exo-section-end() = { + exo-print-solutions(loc: "end-section") +} + +#let exo-chapter-end() = { + exo-print-solutions(loc: "end-chapter") +} + +// Role of a heading of the given level: "chapter", "section" or none. +// A chapter is level 1, a section level 2 — one level deeper each when +// beautitled has parts enabled (a part counts as a chapter boundary). +// Must be called inside context. +#let _heading-role(level) = { + let chapter-level = if _beautitled-parts() { 2 } else { 1 } + if level <= chapter-level { "chapter" } else if level == chapter-level + 1 { "section" } +} + +// Wrap the whole document to automate end-of-chapter and end-of-section +// corrections and numbering: +// - before each chapter heading (and at the end of the document) the pending +// end-section and end-chapter items are printed; after it the exercise +// counter resets per the counter-reset setting +// - before each section heading the pending end-section items are printed; +// with `sections` on, the counter also resets after it (counter-reset +// "section") +// +// sections: auto = on when number-prefix is "section"; true / false to force. +// The levels follow beautitled's `enable-parts` automatically. +// +// #show: exo-auto-chapter +#let exo-auto-chapter(body, sections: auto) = context { + // Corrections printed inside the heading show rule would inherit the + // heading text style (bold, large); capture the base style here to reset it + let base-size = text.size + let base-weight = text.weight + show heading: it => { + // Headings emitted internally by heading-styling packages (e.g. + // beautitled's hidden outline headings, labelled <_btl-internal>) must + // not trigger a chapter boundary: they can sit inside place(hide(..)), + // where the corrections would print invisibly yet be cleared from the + // pending queue + if it.at("label", default: none) == label("_btl-internal") { + return it + } + let level = it.level + // The role is resolved at the heading itself, so exo-setup and + // beautitled-setup calls placed after the show rule are taken into account + context { + let role = _heading-role(level) + if role != none { + set text(size: base-size, weight: base-weight) + exo-print-solutions(loc: "end-section") + if role == "chapter" { exo-chapter-end() } + } + } + it + context { + let role = _heading-role(level) + if role == "chapter" { + exo-chapter-start() + } else if role == "section" { + let on = if sections == auto { + exo-config.get().at("number-prefix", default: none) == "section" + } else { sections } + if on { exo-section-start() } + } + } + } + body + exo-print-solutions(loc: "end-section") + exo-chapter-end() +} + +// ============================================================================= +// Filtering Function +// ============================================================================= + +#let exo-filter( + topic: none, + level: none, + difficulty: none, // Filter: matches the exercise's difficulty level + author: none, // Filter: matches if author is in exercise's authors array + custom: none, // Function (metadata) => bool + show-solutions: true, +) = { + exo-display-counter.step() + context { + let cfg = exo-config.get() + let registry = exo-registry.get() + + for exercise in registry { + let meta = exercise.metadata + let matches = true + + // Check filters + if topic != none and meta.topic != topic { matches = false } + if level != none and meta.level != level { matches = false } + if difficulty != none { + let ex-diff = meta.at("difficulty", default: none) + if ex-diff == none or str(ex-diff) != str(difficulty) { matches = false } + } + // Author filter: check if author is in the authors array + if author != none { + let ex-authors = meta.at("authors", default: ()) + if author not in ex-authors { matches = false } + } + if custom != none and not custom(meta) { matches = false } + + if matches { + // Build exercise flags from stored data + let exercise-flags = ( + solInCorr: exercise.at("solInCorr", default: false), + showCorr: exercise.at("showCorr", default: false), + ) + + if cfg.display != "sol" { + exo-box( + label: get-exercise-label(cfg, meta), + number: exercise.number, + exercise.exercise, + exercise-id: exercise.id, + ref-data: _exo-reference-data( + exercise.id, exercise.number, cfg, meta.at("topic", default: none), + group: exo-display-counter.get().first(), + ), + show-id: cfg.show-id, + label-marker: get-exercise-marker(cfg, meta), + badge-sub: get-difficulty-badge-sub(cfg, meta), + qr: exercise.at("qr", default: none), + badge-color: get-exercise-badge-color(cfg, meta), + title: exercise.at("title", default: none), + ) + } + + // Handle solution/correction display + let worked = exercise.at("worked", default: false) + if (show-solutions and cfg.display != "ex") or worked { + let sol = exercise.at("solution", default: none) + let corr = exercise.at("correction", default: none) + let items-to-show = determine-content-to-show( + sol, corr, cfg, exercise-flags, + qr-sol: exercise.at("qr-sol", default: none), + qr-corr: exercise.at("qr-corr", default: none), + ) + if worked { + items-to-show = _worked-items(items-to-show, sol, corr, cfg, + qr-sol: exercise.at("qr-sol", default: none), + qr-corr: exercise.at("qr-corr", default: none)) + } + show-content-items(items-to-show, exercise.number, exercise.id, cfg, + title: exercise.at("title", default: none)) + } + } + } +} +} + +// ============================================================================= +// Exercise Bank Functions (define without display) +// ============================================================================= + +// Define an exercise without displaying it (for exercise banks) +#let exo-define( + exercise: none, + solution: none, + correction: none, + id: auto, + title: none, // Optional title shown after "Exercise 1" + worked: false, // Worked example: always show the solution after the statement + competencies: (), // List of competency tags + points: none, // Points for exam mode + qr: none, // QR code: URL string or content, placed per badge style + qr-sol: none, // QR code shown on the solution box + qr-corr: none, // QR code shown on the correction box + // Exercise-level display flags + sol-in-corr: false, // If true, correction already contains solution + show-corr: false, // If true, show correction in "mixed" mode + optional: false, // If true, show the optional marker before the exercise label + corr-given: false, // If true, show the corr-given marker (correction handed out) + // Metadata fields + topic: none, + level: none, + difficulty: none, // Difficulty level (key into difficulty-scale, e.g. 1-5) + authors: (), // Array of authors, e.g., ("Nathan", "Raph") + ..extra-metadata +) = { + // Generate unique ID using counter + exo-id-counter.step() + + context { + let id-num = exo-id-counter.get().first() + + // Generate or use provided ID + let exercise-id = if id == auto { + "bank-" + str(id-num) + } else { + id + } + + // Build metadata dictionary + let metadata = ( + topic: topic, + level: level, + difficulty: difficulty, + authors: authors, + optional: optional, + corr-given: corr-given, + ) + // Add extra metadata + for (key, value) in extra-metadata.named() { + metadata.insert(key, value) + } + + // Create exercise record (number will be assigned when displayed) + let exercise-record = ( + id: exercise-id, + number: none, // Not yet assigned + metadata: metadata, + exercise: exercise, + solution: solution, + correction: correction, + competencies: competencies, + points: points, + qr: qr, + qr-sol: qr-sol, + qr-corr: qr-corr, + solInCorr: sol-in-corr, + showCorr: show-corr, + title: title, + worked: worked, + ) + + // Register exercise without displaying + exo-registry.update(reg => { + reg.push(exercise-record) + reg + }) + } +} + +// Show a specific exercise by ID +#let exo-show( + id, + show-solution: auto, // auto = use current config + optional: auto, // auto = use bank metadata; bool = override for this display + optional-symbol: auto, // auto = use current config; content/none = override for this display + corr-given: auto, // auto = use bank metadata; bool = override for this display + corr-given-symbol: auto, // auto = use current config; content/none = override for this display +) = { + exo-counter.step() + exo-display-counter.step() + + context { + let cfg = exo-config.get() + let registry = exo-registry.get() + let num = exo-counter.get().first() + + // Apply page break before if configured + if cfg.page-break == "before" or cfg.page-break == "around" { + pagebreak(weak: true) + } + + // Find exercise by ID + let found = none + for exercise in registry { + if exercise.id == id { + found = exercise + break + } + } + + if found != none { + // Determine if we should show solution + let do-show-solution = if show-solution == auto { + cfg.display != "ex" + } else { + show-solution + } + + // Get competencies and points (with defaults for backward compatibility) + let comps = found.at("competencies", default: ()) + let pts = found.at("points", default: none) + let ex-qr = found.at("qr", default: none) + let ex-qr-sol = found.at("qr-sol", default: none) + let ex-qr-corr = found.at("qr-corr", default: none) + let display-metadata = found.metadata + if optional != auto { + display-metadata.insert("optional", optional) + } + if corr-given != auto { + display-metadata.insert("corr-given", corr-given) + } + let display-cfg = cfg + if optional-symbol != auto { + display-cfg.optional-symbol = optional-symbol + } + if corr-given-symbol != auto { + display-cfg.insert("corr-given-symbol", corr-given-symbol) + } + + // Get solution and correction + let sol = found.at("solution", default: none) + let corr = found.at("correction", default: none) + + // Build exercise flags + let exercise-flags = ( + solInCorr: found.at("solInCorr", default: false), + showCorr: found.at("showCorr", default: false), + ) + + // Displayed number (possibly chapter-prefixed) and difficulty badge color + let disp-num = format-exo-number(cfg, num) + let ex-badge-color = get-exercise-badge-color(cfg, display-metadata) + + // Check display mode + if cfg.display-mode == "exam" { + // EXAM MODE: Use exo-box with points + let exam-cfg = exam-config.get() + + // Display exercise with points in the badge + exo-box( + label: get-exercise-label(display-cfg, display-metadata), + number: disp-num, + found.exercise, + exercise-id: found.id, + ref-data: _exo-reference-data( + found.id, disp-num, cfg, display-metadata.at("topic", default: none), + group: exo-display-counter.get().first(), + ), + show-id: cfg.show-id, + competencies: comps, + show-competencies: cfg.show-competencies, + points: pts, + label-marker: get-exercise-marker(display-cfg, display-metadata), + badge-sub: get-difficulty-badge-sub(display-cfg, display-metadata), + qr: ex-qr, + badge-color: ex-badge-color, + title: found.at("title", default: none), + ) + + // Show solution if configured + if exam-cfg.show-solutions { + let items-to-show = determine-content-to-show(sol, corr, cfg, exercise-flags) + for item in items-to-show { + exam-solution-box(item.content) + } + } + } else { + // EXERCISE MODE: Use default exo-box format (no points) + // Determine what content to show + let items-to-show = determine-content-to-show(sol, corr, cfg, exercise-flags, qr-sol: ex-qr-sol, qr-corr: ex-qr-corr) + // Worked example: solution always shown, right after the statement + let worked = found.at("worked", default: false) + let cfg = if worked { _worked-cfg(cfg) } else { cfg } + if worked { items-to-show = _worked-items(items-to-show, sol, corr, cfg, qr-sol: ex-qr-sol, qr-corr: ex-qr-corr) } + let show-items = items-to-show.len() > 0 and (cfg.display == "sol" or (cfg.display == "both" and do-show-solution) or worked) + let ex-title = found.at("title", default: none) + + // Exercise <-> correction links: only when something is actually deferred + let link-base = none + let link-marker = none + let header-right = none + let exercise-body = found.exercise + if (cfg.display != "sol" and show-items and cfg.at("link-solutions", default: false) + and has-deferred-items(items-to-show, cfg)) { + link-base = str(exo-display-counter.get().first()) + exercise-body = [#make-link-target("exb-ex-" + link-base)#exercise-body] + if cfg.at("link-style", default: "icon") == "page" { + let routed = attach-page-ref( + cfg, + make-page-ref(cfg, link-base, items-to-show, ex-badge-color), + exercise-body, + ) + exercise-body = routed.body + header-right = routed.header-right + } else { + let icon = cfg.at("link-icon", default: none) + if icon != none { + link-marker = link(label("exb-corr-" + link-base), icon) + } + } + } + + // Display exercise based on show mode + if cfg.display != "sol" { + exo-box( + label: get-exercise-label(display-cfg, display-metadata), + number: disp-num, + exercise-body, + exercise-id: found.id, + ref-data: _exo-reference-data( + found.id, disp-num, cfg, display-metadata.at("topic", default: none), + group: exo-display-counter.get().first(), + ), + show-id: cfg.show-id, + competencies: comps, + show-competencies: cfg.show-competencies, + label-marker: get-exercise-marker(display-cfg, display-metadata, extra: link-marker), + badge-sub: get-difficulty-badge-sub(display-cfg, display-metadata), + qr: ex-qr, + badge-color: ex-badge-color, + header-right: header-right, + title: ex-title, + ) + } + + // Handle solution/correction display (per-type location) + if show-items { + if cfg.display == "sol" { + show-content-items(items-to-show, disp-num, found.id, cfg, title: ex-title) + } else { + place-content-items(items-to-show, disp-num, found.id, cfg, link-base: link-base, title: ex-title) + } + } + } + + // Apply page break after if configured + if cfg.page-break == "after" or cfg.page-break == "around" { + pagebreak(weak: true) + } + } else { + text(fill: red)[*Error: Exercise "#id" not found*] + } + } +} + +// Show multiple exercises by IDs +#let exo-show-many(..ids, show-solution: auto) = { + for id in ids.pos() { + exo-show(id, show-solution: show-solution) + } +} + +// ============================================================================= +// Advanced Filtering +// ============================================================================= + +// Filter and display exercises with flexible criteria +#let exo-select( + // Simple filters (match exact value) + topic: none, + level: none, + difficulty: none, // Filter by difficulty level + author: none, // Filter: matches if author is in exercise's authors array + competency: none, // Filter by single competency + // List filters (match any in list) + topics: none, // e.g., ("algebra", "geometry") + levels: none, // e.g., ("1M", "2M") + difficulties: none, // e.g., (1, 2) - match any + competencies: none, // e.g., ("C1.1", "C2.3") - match any + // Custom filter function + where: none, // Function (exercise) => bool + // Display options + show-solutions: auto, + renumber: true, // Renumber exercises sequentially + max: none, // Maximum number to show + shuffle: false, // Randomize order (needs seed) +) = { + // Stepped outside context: base for document-unique link label names + exo-display-counter.step() + + context { + let cfg = exo-config.get() + let registry = exo-registry.get() + let call-base = exo-display-counter.get().first() + + // Determine solution display + let do-show-solutions = if show-solutions == auto { + cfg.display != "ex" + } else { + show-solutions + } + + // Filter exercises + let filtered = () + for exercise in registry { + let meta = exercise.metadata + let matches = true + + // Simple exact filters + if topic != none and meta.topic != topic { matches = false } + if level != none and meta.level != level { matches = false } + if difficulty != none { + let ex-diff = meta.at("difficulty", default: none) + if ex-diff == none or str(ex-diff) != str(difficulty) { matches = false } + } + // Author filter: check if author is in the authors array + if author != none { + let ex-authors = meta.at("authors", default: ()) + if author not in ex-authors { matches = false } + } + + // List filters (match any) + if topics != none { + let found = false + for t in topics { + if meta.topic == t { found = true; break } + } + if not found { matches = false } + } + if levels != none { + let found = false + for l in levels { + if meta.level == l { found = true; break } + } + if not found { matches = false } + } + if difficulties != none { + let ex-diff = meta.at("difficulty", default: none) + let found = false + if ex-diff != none { + for d in difficulties { + if str(ex-diff) == str(d) { found = true; break } + } + } + if not found { matches = false } + } + + // Competency filters + let ex-comps = exercise.at("competencies", default: ()) + if competency != none { + if competency not in ex-comps { matches = false } + } + if competencies != none { + let found = false + for c in competencies { + if c in ex-comps { found = true; break } + } + if not found { matches = false } + } + + // Custom filter + if where != none and not where(exercise) { matches = false } + + if matches { + filtered.push(exercise) + } + } + + // Apply max limit + if max != none and filtered.len() > max { + filtered = filtered.slice(0, max) + } + + // Display filtered exercises + let display-num = 0 + for exercise in filtered { + display-num += 1 + let num = if renumber { display-num } else { exercise.number } + let ex-comps = exercise.at("competencies", default: ()) + let pts = exercise.at("points", default: none) + let ex-qr = exercise.at("qr", default: none) + let sol = exercise.at("solution", default: none) + let corr = exercise.at("correction", default: none) + + // Build exercise flags + let exercise-flags = ( + solInCorr: exercise.at("solInCorr", default: false), + showCorr: exercise.at("showCorr", default: false), + ) + + // Displayed number (possibly chapter-prefixed) and difficulty badge color + let disp-num = format-exo-number(cfg, num) + let ex-badge-color = get-exercise-badge-color(cfg, exercise.metadata) + + // Apply page break before if configured + if cfg.page-break == "before" or cfg.page-break == "around" { + pagebreak(weak: true) + } + + // Check display mode + if cfg.display-mode == "exam" { + // EXAM MODE: Use exo-box with points + let exam-cfg = exam-config.get() + + // Display exercise with points in the badge + exo-box( + label: get-exercise-label(cfg, exercise.metadata), + number: disp-num, + exercise.exercise, + exercise-id: exercise.id, + ref-data: _exo-reference-data( + exercise.id, disp-num, cfg, exercise.metadata.at("topic", default: none), + group: call-base, + ), + show-id: cfg.show-id, + competencies: ex-comps, + show-competencies: cfg.show-competencies, + points: pts, + label-marker: get-exercise-marker(cfg, exercise.metadata), + badge-sub: get-difficulty-badge-sub(cfg, exercise.metadata), + qr: ex-qr, + badge-color: ex-badge-color, + title: exercise.at("title", default: none), + ) + + // Show solution if configured + if exam-cfg.show-solutions { + let items-to-show = determine-content-to-show(sol, corr, cfg, exercise-flags) + for item in items-to-show { + exam-solution-box(item.content) + } + } + } else { + // EXERCISE MODE: Use default exo-box format (no points) + // Determine what content to show + let items-to-show = determine-content-to-show( + sol, corr, cfg, exercise-flags, + qr-sol: exercise.at("qr-sol", default: none), + qr-corr: exercise.at("qr-corr", default: none), + ) + // Worked example: solution always shown, right after the statement + let worked = exercise.at("worked", default: false) + let cfg = if worked { _worked-cfg(cfg) } else { cfg } + if worked { + items-to-show = _worked-items(items-to-show, sol, corr, cfg, + qr-sol: exercise.at("qr-sol", default: none), + qr-corr: exercise.at("qr-corr", default: none)) + } + let show-items = items-to-show.len() > 0 and (cfg.display == "sol" or do-show-solutions or worked) + let ex-title = exercise.at("title", default: none) + + // Exercise <-> correction links: only when something is actually deferred. + // Label base combines the call base and the loop index for uniqueness. + let link-base = none + let link-marker = none + let header-right = none + let exercise-body = exercise.exercise + if (cfg.display != "sol" and show-items and cfg.at("link-solutions", default: false) + and has-deferred-items(items-to-show, cfg)) { + link-base = str(call-base) + "-" + str(display-num) + exercise-body = [#make-link-target("exb-ex-" + link-base)#exercise-body] + if cfg.at("link-style", default: "icon") == "page" { + let routed = attach-page-ref( + cfg, + make-page-ref(cfg, link-base, items-to-show, ex-badge-color), + exercise-body, + ) + exercise-body = routed.body + header-right = routed.header-right + } else { + let icon = cfg.at("link-icon", default: none) + if icon != none { + link-marker = link(label("exb-corr-" + link-base), icon) + } + } + } + + if cfg.display != "sol" { + exo-box( + label: get-exercise-label(cfg, exercise.metadata), + number: disp-num, + exercise-body, + exercise-id: exercise.id, + ref-data: _exo-reference-data( + exercise.id, disp-num, cfg, exercise.metadata.at("topic", default: none), + group: call-base, + ), + show-id: cfg.show-id, + competencies: ex-comps, + show-competencies: cfg.show-competencies, + label-marker: get-exercise-marker(cfg, exercise.metadata, extra: link-marker), + badge-sub: get-difficulty-badge-sub(cfg, exercise.metadata), + qr: ex-qr, + badge-color: ex-badge-color, + header-right: header-right, + title: ex-title, + ) + } + + // Handle solution/correction display (per-type location) + if show-items { + if cfg.display == "sol" { + show-content-items(items-to-show, disp-num, exercise.id, cfg, title: ex-title) + } else { + place-content-items(items-to-show, disp-num, exercise.id, cfg, link-base: link-base, title: ex-title) + } + } + } + + // Apply page break after if configured + if cfg.page-break == "after" or cfg.page-break == "around" { + pagebreak(weak: true) + } + } + } // close context +} + +// ============================================================================= +// Utility Functions +// ============================================================================= + +#let exo-get-registry() = context { + exo-registry.get() +} + +#let exo-clear-registry() = { + exo-registry.update(()) +} + +// Count exercises matching criteria +#let exo-count( + topic: none, + level: none, + difficulty: none, // Filter: matches the exercise's difficulty level + author: none, // Filter: matches if author is in exercise's authors array +) = context { + let registry = exo-registry.get() + let count = 0 + + for exercise in registry { + let meta = exercise.metadata + let matches = true + + if topic != none and meta.topic != topic { matches = false } + if level != none and meta.level != level { matches = false } + if difficulty != none { + let ex-diff = meta.at("difficulty", default: none) + if ex-diff == none or str(ex-diff) != str(difficulty) { matches = false } + } + // Author filter: check if author is in the authors array + if author != none { + let ex-authors = meta.at("authors", default: ()) + if author not in ex-authors { matches = false } + } + + if matches { count += 1 } + } + + count +} + +// ============================================================================= +// Exam Integration +// ============================================================================= + +// Custom score table that works with exo-box questions +#let exam-score-table( + ..exercise-ids, + question-label: "Question", + points-label: "Points", + grade-label: "Note", + total-label: "Total", +) = context { + let registry = exo-registry.get() + let ids = exercise-ids.pos() + + // Collect points for each exercise + let points-list = () + let total = 0 + + for (idx, id) in ids.enumerate() { + for exercise in registry { + if exercise.id == id { + let pts = exercise.at("points", default: 0) + points-list.push((num: idx + 1, points: pts)) + total += pts + break + } + } + } + + // Build table + let cols = points-list.len() + 1 + + table( + columns: (auto,) * (cols + 1), + align: center, + stroke: 0.5pt, + // Header row + [*#question-label*], ..points-list.map(p => str(p.num)), [*#total-label*], + // Points row + [*#points-label*], ..points-list.map(p => str(p.points)), [*#total*], + // Grade row (empty for filling in) + [*#grade-label*], ..points-list.map(p => []), [], + ) +} + +// Setup exam configuration (exam-config state is defined at top of file) +#let exam-setup( + show-solutions: none, + solution-label: none, + solution-fill: none, + solution-stroke: none, + solution-radius: none, + solution-inset: none, + solution-label-color: none, + question-spacing: none, +) = { + exam-config.update(cfg => { + let new = cfg + if show-solutions != none { new.show-solutions = show-solutions } + if solution-label != none { new.solution-label = solution-label } + if solution-fill != none { new.solution-fill = solution-fill } + if solution-stroke != none { new.solution-stroke = solution-stroke } + if solution-radius != none { new.solution-radius = solution-radius } + if solution-inset != none { new.solution-inset = solution-inset } + if solution-label-color != none { new.solution-label-color = solution-label-color } + if question-spacing != none { new.question-spacing = question-spacing } + new + }) +} + +// exam-solution-box is defined at top of file diff --git a/packages/preview/exercise-bank/0.7.0/typst.toml b/packages/preview/exercise-bank/0.7.0/typst.toml new file mode 100644 index 0000000000..05a14caa56 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/typst.toml @@ -0,0 +1,13 @@ +[package] +name = "exercise-bank" +version = "0.7.0" +entrypoint = "lib.typ" +authors = ["Nathan Scheinmann"] +license = "MIT" +description = "Exercise management with solutions, metadata, filtering, and exercise banks for educational documents" +repository = "https://github.com/nathan-ed/typst-package-exercise-bank" +keywords = ["exercises", "solutions", "education", "bank", "teaching", "mathematics", "filtering"] +categories = ["components", "utility"] +disciplines = ["mathematics", "education"] +compiler = "0.11.0" +exclude = ["docs/*", "gallery", "tests", "*.pdf"] From f3c13775f3b3b78eaf74414011b3a31df9e46d56 Mon Sep 17 00:00:00 2001 From: Nathan <22835124+nathan-ed@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:52:50 +0200 Subject: [PATCH 2/3] =?UTF-8?q?exercise-bank:0.7.0=20=E2=80=94=20points=20?= =?UTF-8?q?of=20an=20exercise=20(points-position,=20points-label,=20points?= =?UTF-8?q?-format)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/preview/exercise-bank/0.7.0/lib.typ | 113 ++++++++++++++++--- 1 file changed, 99 insertions(+), 14 deletions(-) diff --git a/packages/preview/exercise-bank/0.7.0/lib.typ b/packages/preview/exercise-bank/0.7.0/lib.typ index a13bd28d3b..8fb20a7e76 100644 --- a/packages/preview/exercise-bank/0.7.0/lib.typ +++ b/packages/preview/exercise-bank/0.7.0/lib.typ @@ -162,6 +162,12 @@ // Exercise titles "title-separator": [ -- ], // Between "Exercise 1" and the title "title-format": auto, // auto, or function (title) => content + "points-label": "pts", // Unit after the points ("pts", "points") + "points-position": "below", // "below" (under the badge or label), "right" (right end + // of the header line) or "badge" (next to the badge, + // without widening the label column) + "points-format": auto, // auto, "score" ("…… / 4 pts", left blank for the mark), + // or function (points, label) => content "title-in-solutions": false, // Repeat the title on solution/correction boxes // Header spacing (full-width styles and badge-position "above"; auto = style default) "underline-gap": auto, // Header lower edge -> rule centre; auto follows header-rule-gap @@ -322,6 +328,9 @@ // Exercise titles title-separator: auto, // Between "Exercise 1" and the title (auto = keep current) title-format: none, // auto or function (title) => content + points-label: none, // Unit after the points ("pts", "points") + points-position: none, // "below", "right" or "badge" + points-format: none, // auto, "score" or function (points, label) => content title-in-solutions: none, // Repeat the title on solution/correction boxes // Header spacing underline-gap: none, // Header lower edge -> rule centre (auto = legacy option/default) @@ -411,6 +420,15 @@ if number-prefix-depth != none { new.number-prefix-depth = number-prefix-depth } if title-separator != auto { new.title-separator = title-separator } if title-format != none { new.title-format = title-format } + if points-label != none { new.points-label = points-label } + if points-position != none { + assert( + points-position in ("below", "right", "badge"), + message: "points-position must be \"below\", \"right\" or \"badge\"", + ) + new.points-position = points-position + } + if points-format != none { new.points-format = points-format } if title-in-solutions != none { new.title-in-solutions = title-in-solutions } if underline-gap != none { new.underline-gap = underline-gap } if underline-below != none { new.underline-below = underline-below } @@ -1053,6 +1071,9 @@ #let style-margin( label, number, body, font-size, color, is-solution, qr: none, + sub: none, // Line right under the side label (e.g. the points) + aside: none, // Right end of the label line once folded, of the statement's + // first line otherwise (e.g. the points) label-width: 3.35cm, gutter: 0.55cm, fold-below: auto, @@ -1072,13 +1093,15 @@ ] } else { let side-label = text(size: font-size, weight: "bold", fill: color)[#label~#number:] + let folded-label = if aside == none { side-label } else { [#side-label#h(6pt)#aside] } let folded = { // Header line: the rule runs the whole measure, the label sits under it // on the right, the statement keeps the full width below block(width: 100%, breakable: false, { line(length: 100%, stroke: 0.45pt + color) v(-0.2em) - align(right, side-label) + align(right, folded-label) + if sub != none { block(above: 0.15em, width: 100%, align(right, sub)) } if qr != none { v(0.45em); align(right, qr) } }) block(above: 0.35em, width: 100%, body) @@ -1091,6 +1114,7 @@ #line(length: 100%, stroke: 0.45pt + color) #v(-0.2em) #align(right)[#side-label] + #if sub != none { block(above: 0.15em, width: 100%, align(right, sub)) } #if qr != none { v(0.45em) align(right, qr) @@ -1099,7 +1123,9 @@ [ #line(length: 0pt, stroke: 0.45pt + white) #v(-0.2em) - #body + #if aside == none { body } else { + grid(columns: (1fr, auto), column-gutter: 8pt, body, aside) + } ], ) if fold == true { @@ -1379,6 +1405,8 @@ #let get-fullwidth-style( style, label, number, body, font-size, color, is-solution, qr: none, + margin-sub: none, + margin-aside: none, margin-label-width: 3.35cm, margin-label-gutter: 0.55cm, margin-fold-below: auto, @@ -1389,6 +1417,8 @@ style-margin( label, number, body, font-size, color, is-solution, qr: qr, + sub: margin-sub, + aside: margin-aside, label-width: margin-label-width, gutter: margin-label-gutter, fold-below: margin-fold-below, @@ -1470,7 +1500,7 @@ competencies: (), // List of competencies show-competencies: false, // Whether to show competencies points: none, // Points for exam mode - points-label: "pts", // Label for points (e.g., "pts", "points") + points-label: auto, // Label for points (auto = points-label of the configuration) label-marker: none, // Optional marker displayed before the badge label badge-sub: none, // Optional marker displayed right below the badge (e.g., difficulty) margin-content: none, // Optional content below the badge (e.g., QR code, remarks) @@ -1505,6 +1535,27 @@ let title-strong = if title != none { if title-fmt == auto { strong(title) } else { title-fmt(title) } } + + // Points: under the badge or label ("below"), at the right end of the header + // line ("right") or next to the badge ("badge") + let points-content = if points != none { + let unit = if points-label == auto { cfg.at("points-label", default: "pts") } else { points-label } + let fmt = cfg.at("points-format", default: auto) + if fmt == "score" { + // Blank for the mark, then the scale: "…… / 4 pts" + text(style: "italic", weight: "regular")[#box(width: 2.2em, repeat[.])~/~#points~#unit] + } else if fmt == auto and is-fullwidth-style(cfg.badge-style) { + // Full-width headers: same colour and size as the header text, so the + // points stay readable on a filled strip (header-card) + text(style: "italic", weight: "regular")[(#points #unit)] + } else if fmt == auto { + text(size: 9pt, fill: rgb("#555"), style: "italic")[(#points #unit)] + } else { fmt(points, unit) } + } + let points-position = cfg.at("points-position", default: "below") + let points-right = points-content != none and points-position == "right" + let points-next = points-content != none and points-position == "badge" + let points-below = points-content != none and not (points-right or points-next) let header-body-gap = cfg.at("header-body-gap", default: auto) // Determine the color based on box type @@ -1593,6 +1644,12 @@ } else { full-body = qr-attach-body(make-exo-qr(qr, cfg), full-body, cfg) } + // "margin": the side label is too narrow to carry the points next to it, + // "below" and "badge" both give them their own line under it (above the + // QR code, if any); "right" goes to the end of the label line once folded, + // to the right of the statement's first line otherwise (see style-margin) + let margin-in-body = cfg.badge-style == "margin" and not is-solution + let points-in-side = margin-in-body and (points-below or points-next) // The page reference (link-style "page") gets a right-aligned line of its // own above the statement: the full-width styles have no badge line to pin // it to, and wrapping the statement around it is what used to destabilise @@ -1608,15 +1665,22 @@ // statement there; the other styles carry it in their header line let number = number if title != none { - if cfg.badge-style == "margin" and not is-solution { + if margin-in-body { full-body = { block(spacing: 0pt, below: 0.6em, title-strong) full-body } } else { + if points-next { number = [#number#h(6pt)#points-content] } number = [#number#cfg.at("title-separator", default: [ -- ])#title-inline] } + } else if points-next and not margin-in-body { + number = [#number#h(6pt)#points-content] } + // Header line of the other full-width styles: points at its right end, or + // on a second line right under it (no gap, unlike a line of the statement) + if points-right and not margin-in-body { number = [#number#h(1fr)#points-content] } + if points-below and not margin-in-body { number = [#number\ #points-content] } block( above: space-above, @@ -1633,6 +1697,8 @@ actual-color, is-solution, qr: qr-block, + margin-sub: if points-in-side { points-content }, + margin-aside: if margin-in-body and points-right { points-content }, margin-label-width: cfg.at("margin-label-width", default: 3.35cm), margin-label-gutter: cfg.at("margin-label-gutter", default: 0.55cm), margin-fold-below: cfg.at("margin-fold-below", default: auto), @@ -1662,12 +1728,6 @@ metrics: badge-metrics(cfg), ) - // Badge with optional points displayed inline after the box - let badge-with-points = if points != none { - box[#badge#h(6pt)#text(size: 9pt, fill: rgb("#555"), style: "italic")[(#points #points-label)]] - } else { - badge - } // ID display below badge (for badge styles) let id-block = if show-id and exercise-id != none { @@ -1683,8 +1743,9 @@ // costs one line, once. let header-left = { set text(hyphenate: false) - box[#badge-with-points] + box[#badge] if badge-sub != none { h(4pt); badge-sub } + if points-next { h(6pt); points-content } if title != none { h(6pt); title-strong } if show-id and exercise-id != none { id-block } } @@ -1703,6 +1764,11 @@ width: 100%, breakable: true, )[ + #let header-right = if not points-right { header-right } else if header-right == none { + points-content + } else { + [#header-right#h(8pt)#points-content] + } #sticky-block(if header-right == none { header-left } else { @@ -1714,6 +1780,7 @@ header-right, ) }) + #if points-below { sticky-block(above: 2pt, points-content) } #if qr-block != none { sticky-block(above: 4pt, qr-block) } #if margin-content != none { sticky-block(above: 4pt, margin-content) } #block(above: if header-body-gap == auto { 4pt } else { header-body-gap }, width: 100%, breakable: true)[ @@ -1759,7 +1826,8 @@ set text(hyphenate: false) block(width: label-col-min, height: 0pt, spacing: 0pt) align(right)[ - #box[#badge-with-points] + #box[#badge] + #if points-below { block(above: 2pt, points-content) } #if badge-sub != none { block(above: 3pt, badge-sub) } @@ -1797,10 +1865,23 @@ )[ #set par(first-line-indent: 0cm) #v(3pt) // Align with badge text + // "badge": the points open the statement, right after the label + // column, which keeps its width (the badge does not move) #if title != none { - block(spacing: 0pt, below: if header-body-gap == auto { 0.6em } else { header-body-gap }, title-strong) + let title-line = if points-right { + [#title-strong#h(1fr)#points-content] + } else if points-next { + [#points-content#h(6pt)#title-strong] + } else { title-strong } + block(spacing: 0pt, below: if header-body-gap == auto { 0.6em } else { header-body-gap }, width: 100%, title-line) } - #content-body + // No title line to carry them: the points sit on the right of the + // statement's first line, in their own column + #if title == none and points-right { + grid(columns: (1fr, auto), column-gutter: 8pt, content-body, points-content) + } else if title == none and points-next { + grid(columns: (auto, 1fr), column-gutter: 6pt, points-content, content-body) + } else { content-body } #if show-competencies and competencies.len() > 0 { comp-block } ], ) @@ -2221,6 +2302,8 @@ correction: none, id: auto, title: none, // Optional title shown after "Exercise 1" + points: none, // Points of the exercise, shown after the number or the title + // (see points-position in exo-setup) worked: false, // Worked example: always show the solution/correction // right after the statement (even with display: "ex" // or a deferred corr-loc) @@ -2329,6 +2412,7 @@ qr: qr, badge-color: ex-badge-color, title: title, + points: points, ) // Worked example: its solution shows even in the exercises-only version if worked { @@ -2388,6 +2472,7 @@ badge-color: ex-badge-color, header-right: header-right, title: title, + points: points, ) // Handle solution/correction display (per-type location) From 6207e8190c90ea4b7aae4a253639e2f7b3a11204 Mon Sep 17 00:00:00 2001 From: Nathan <22835124+nathan-ed@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:57:09 +0200 Subject: [PATCH 3/3] exercise-bank:0.7.0 --- .../preview/exercise-bank/0.7.0/README.md | 28 +++++++++++++-- .../exercise-bank/0.7.0/gallery/points.svg | 1 + .../exercise-bank/0.7.0/gallery/points.typ | 35 +++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/points.svg create mode 100644 packages/preview/exercise-bank/0.7.0/gallery/points.typ diff --git a/packages/preview/exercise-bank/0.7.0/README.md b/packages/preview/exercise-bank/0.7.0/README.md index a798d20640..65218da179 100644 --- a/packages/preview/exercise-bank/0.7.0/README.md +++ b/packages/preview/exercise-bank/0.7.0/README.md @@ -1,7 +1,7 @@ # exercise-bank [![exercise-bank on Typst Universe](https://img.shields.io/badge/Typst_Universe-v._0.7.0-239dad?labelColor=eee)](https://typst.app/universe/package/exercise-bank) -[![Full package manual as PDF](https://img.shields.io/badge/Manual-pdf-333333?labelColor=eee)](https://github.com/nathan-ed/typst-package-exercise-bank/blob/aca0d3d07fe2718345ec800a2d1772212ec72261/docs/manual.pdf) +[![Full package manual as PDF](https://img.shields.io/badge/Manual-pdf-333333?labelColor=eee)](https://github.com/nathan-ed/typst-package-exercise-bank/blob/e3110cdf59fdac18c32599cfba23eae2c6b60d1a/docs/manual.pdf) [![Distributed under the MIT license](https://img.shields.io/badge/License-MIT-333333?labelColor=eee)](LICENSE) A comprehensive Typst package for creating and managing exercises with solutions, metadata, filtering, and exercise banks. Perfect for teachers, textbook authors, and educational content creators. @@ -28,8 +28,8 @@ Click on an image to see the source code. | Badge Position | Two-Column Layouts | Badge Size | | [![Exercise titles and a worked example whose solution stays visible in the student version](gallery/titles-worked.svg)](gallery/titles-worked.typ) | [![Exercises numbered per section with solutions printed before each new section](gallery/section-numbering.svg)](gallery/section-numbering.typ) | | | Titles and Worked Examples | Per-Section Numbering | | -| [![Clickable exercise references with their page and originating beautitled part](gallery/references-1.svg)](gallery/references.typ) | | | -| Exercise References | | | +| [![Clickable exercise references with their page and originating beautitled part](gallery/references-1.svg)](gallery/references.typ) | [![A short test with the points of each exercise and a blank for the mark](gallery/points.svg)](gallery/points.typ) | | +| Exercise References | Points | | ## Features @@ -45,6 +45,7 @@ Click on an image to see the source code. - **Split solution/correction placement** - Short solution under the statement (epigraph-style), full correction at the end of the chapter - **Chapter-prefixed numbering** - Number exercises as "3.5" using the current heading number, "3.2.5" with two heading levels, or a series number of your choice (`number-prefix: 3`) - **Exercise titles** - "Exercise 1 – Pythagorean theorem" with `exo(title: ..)` +- **Points** - `exo(points: 4)` shows the value of an exercise under the badge, at the end of the header line or next to the badge, optionally as "…… / 4 pts" for the mark - **Worked examples** - `exo(worked: true)` shows its solution right under the statement, even in the student version - **Compact headers** - `header-rule-gap` and `header-body-gap` tighten the space between the label, its rule and the statement - **Automatic end-of-chapter corrections** - `#show: exo-auto-chapter` prints pending corrections before each new chapter @@ -657,6 +658,21 @@ The label column follows the badge as it is resized, so `badge-position: "margin The full-width styles (`border-accent`, `underline`, `rounded-box`, `header-card`) put the title in their header line after `title-separator` (default an en dash): "Exercice 1 – Calculer une longueur…". The badge styles show it in bold next to the badge, and `margin` opens the statement with it. `title-format` takes a function `(title) => content` to restyle it, and `title-in-solutions: true` repeats it on the solution and correction boxes. `exo-define` accepts `title` too. +### Points + +```typst +#exo-setup(points-position: "right", points-format: "score") +#exo(title: [Théorème de Pythagore], points: 4, exercise: [Calculer $A C$.]) +``` + +`points` shows the value of an exercise; solutions and corrections never repeat it. `points-position` places it: + +- `"below"` (default): on its own line under the badge, under the side label for `margin`, or right under the header text for the other full-width styles; +- `"right"`: at the right end of the header line, or of the title line when the badge is in the margin (`margin` once folded: at the end of its label line); +- `"badge"`: next to the badge, at the start of the statement when the badge is in the margin, so the label column keeps its width and the badge does not move (`margin` shows the points below its label). + +`points-label` sets the unit (`"pts"` by default). `points-format: "score"` prints "…… / 4 pts", leaving the mark to fill in; a function `(points, label) => content` replaces the whole rendering. By default the badge styles show the points in small grey italics, the full-width styles in the colour and size of their header. + ### Header Spacing The header of the full-width styles and of `badge-position: "above"` can be tightened (or loosened): @@ -1106,6 +1122,7 @@ Level 1M exercises: #exo-count(level: "1M") | `correction` | content | none | Correction content (teacher version) | | `id` | string/auto | auto | Unique exercise ID | | `title` | content | none | Title shown after "Exercise 1" | +| `points` | number/content | none | Points of the exercise (see `points-position`) | | `worked` | bool | false | Worked example: always show the solution/correction right after the statement | | `sol-in-corr` | bool | false | If true, solution is in correction (show only correction, not both) | | `show-corr` | bool | false | If true, show correction in "mixed" mode | @@ -1126,6 +1143,7 @@ Level 1M exercises: #exo-count(level: "1M") | `correction` | content | none | Correction content (teacher version) | | `id` | string/auto | auto | Unique exercise ID | | `title` | content | none | Title shown after "Exercise 1" | +| `points` | number/content | none | Points of the exercise (see `points-position`) | | `worked` | bool | false | Worked example: always show the solution/correction right after the statement | | `competencies` | array | () | List of competency tags | | `sol-in-corr` | bool | false | If true, solution is in correction (show only correction) | @@ -1187,6 +1205,9 @@ Level 1M exercises: #exo-count(level: "1M") | `number-separator` | string | "." | Separator for chapter-prefixed numbers | | `title-separator` | content | `[ -- ]` | Between "Exercise 1" and the title | | `title-format` | auto/function | auto | Restyle titles: (title) => content | +| `points-label` | string/content | "pts" | Unit after the points of an exercise | +| `points-position` | string | "below" | "below" (under the badge or header), "right" (end of the header line) or "badge" (next to the badge) | +| `points-format` | auto/"score"/function | auto | "score" ("…… / 4 pts") or (points, label) => content | | `title-in-solutions` | bool | false | Repeat the title on solution/correction boxes | | `underline-gap` | length/auto | auto | underline only: title lower edge (normally baseline) → rule centre; overrides `header-rule-gap`; `none` keeps current value | | `underline-below` | length/auto | auto | underline only: rule centre → body top; overrides `header-body-gap`; `none` keeps current value | @@ -1301,6 +1322,7 @@ MIT License - see LICENSE file for details. #### Added - **Exercise citations.** `exo-cite(id)` references the actual displayed number and page, optionally the beautitled part, with forward/backward links, occurrence selection and display-call labels. Works across included chapter files and filtered selections spanning pages. Based on [Arthur Meyer’s proposal in issue #2](https://github.com/nathan-ed/typst-package-exercise-bank/issues/2), extended to use individual display anchors. +- **Points of an exercise.** `exo(points: ..)` shows its value on the statement: under the badge or header text (`points-position: "below"`, default), at the end of the header line (`"right"`) or next to the badge without widening the label column (`"badge"`). `points-label` sets the unit, `points-format: "score"` prints "…… / 4 pts" for the mark. The full-width styles now show the points of `exo-show` exams too. - **Underline spacing controls.** `underline-gap` (title lower edge, normally its baseline, to rule centre) and `underline-below` (rule centre to body top), with `none` leaving configuration unchanged and `auto` following the existing header settings; apply to exercises, solutions and corrections in every display mode. #### Fixed diff --git a/packages/preview/exercise-bank/0.7.0/gallery/points.svg b/packages/preview/exercise-bank/0.7.0/gallery/points.svg new file mode 100644 index 0000000000..2cff2c2bf9 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/points.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.7.0/gallery/points.typ b/packages/preview/exercise-bank/0.7.0/gallery/points.typ new file mode 100644 index 0000000000..4f6a227b10 --- /dev/null +++ b/packages/preview/exercise-bank/0.7.0/gallery/points.typ @@ -0,0 +1,35 @@ +// Points of an exercise - exercise-bank +// A short test: the scale on the right, a blank for the mark + +#import "@preview/exercise-bank:0.7.0": * + +#set page(width: 14cm, height: auto, margin: 1cm) +#set text(font: "New Computer Modern", size: 11pt, lang: "fr") + +#exo-setup( + display: "ex", + badge-style: "underline", + exercise-label: "Exercice", + points-position: "right", // right end of the header line + points-format: "score", // "...... / 4 pts", the mark is left blank +) + +#exo( + title: [Théorème de Pythagore], + points: 4, + exercise: [Dans le triangle $A B C$ rectangle en $B$, $A B = 3$ et $B C = 4$. + Calculer $A C$.], +) + +#exo( + title: [Puissances], + points: 2, + exercise: [Simplifier $e^3 times e^4$.], +) + +#exo-setup(badge-style: "box", points-position: "below", points-format: auto) + +#exo( + points: 1.5, + exercise: [Calculer $2^3 + 3^2$.], +)