From abb6d63af5bb86a89eeb797efc3c81902bf99d88 Mon Sep 17 00:00:00 2001 From: Nathan <22835124+nathan-ed@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:09:38 +0200 Subject: [PATCH] exercise-bank:0.6.1 --- .../preview/exercise-bank/0.6.1/.gitignore | 8 + packages/preview/exercise-bank/0.6.1/LICENSE | 21 + .../preview/exercise-bank/0.6.1/README.md | 1062 +++++++ .../exercise-bank/0.6.1/gallery/bank.svg | 1 + .../exercise-bank/0.6.1/gallery/bank.typ | 33 + .../exercise-bank/0.6.1/gallery/basic.svg | 1 + .../exercise-bank/0.6.1/gallery/basic.typ | 17 + .../0.6.1/gallery/competencies.svg | 1 + .../0.6.1/gallery/competencies.typ | 26 + .../0.6.1/gallery/corrections-only.svg | 1 + .../0.6.1/gallery/corrections-only.typ | 49 + .../0.6.1/gallery/corrections.svg | 1 + .../0.6.1/gallery/corrections.typ | 50 + .../0.6.1/gallery/difficulty.svg | 1 + .../0.6.1/gallery/difficulty.typ | 54 + .../0.6.1/gallery/draft-mode-1.svg | 1 + .../0.6.1/gallery/draft-mode-2.svg | 1 + .../0.6.1/gallery/draft-mode-3.svg | 1 + .../0.6.1/gallery/draft-mode.typ | 80 + .../0.6.1/gallery/end-chapter.svg | 1 + .../0.6.1/gallery/end-chapter.typ | 38 + .../0.6.1/gallery/end-section.svg | 1 + .../0.6.1/gallery/end-section.typ | 27 + .../exercise-bank/0.6.1/gallery/filtering.svg | 1 + .../exercise-bank/0.6.1/gallery/filtering.typ | 47 + .../0.6.1/gallery/inline-solutions.svg | 1 + .../0.6.1/gallery/inline-solutions.typ | 36 + .../0.6.1/gallery/linked-solutions.svg | 1 + .../0.6.1/gallery/linked-solutions.typ | 49 + .../exercise-bank/0.6.1/gallery/markers.svg | 1 + .../exercise-bank/0.6.1/gallery/markers.typ | 53 + .../0.6.1/gallery/mixed-display.svg | 1 + .../0.6.1/gallery/mixed-display.typ | 56 + .../0.6.1/gallery/pagebreak-1.svg | 1 + .../0.6.1/gallery/pagebreak-2.svg | 1 + .../0.6.1/gallery/pagebreak-3.svg | 1 + .../exercise-bank/0.6.1/gallery/pagebreak.typ | 29 + .../exercise-bank/0.6.1/gallery/qr-codes.svg | 1 + .../exercise-bank/0.6.1/gallery/qr-codes.typ | 42 + .../exercise-bank/0.6.1/gallery/solutions.svg | 1 + .../exercise-bank/0.6.1/gallery/solutions.typ | 36 + .../exercise-bank/0.6.1/gallery/styles.svg | 1 + .../exercise-bank/0.6.1/gallery/styles.typ | 45 + packages/preview/exercise-bank/0.6.1/lib.typ | 2483 +++++++++++++++++ .../preview/exercise-bank/0.6.1/typst.toml | 13 + 45 files changed, 4376 insertions(+) create mode 100644 packages/preview/exercise-bank/0.6.1/.gitignore create mode 100644 packages/preview/exercise-bank/0.6.1/LICENSE create mode 100644 packages/preview/exercise-bank/0.6.1/README.md create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/bank.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/bank.typ create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/basic.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/basic.typ create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/competencies.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/competencies.typ create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/corrections-only.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/corrections-only.typ create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/corrections.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/corrections.typ create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/difficulty.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/difficulty.typ create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/draft-mode-1.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/draft-mode-2.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/draft-mode-3.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/draft-mode.typ create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/end-chapter.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/end-chapter.typ create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/end-section.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/end-section.typ create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/filtering.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/filtering.typ create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/inline-solutions.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/inline-solutions.typ create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/linked-solutions.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/linked-solutions.typ create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/markers.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/markers.typ create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/mixed-display.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/mixed-display.typ create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/pagebreak-1.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/pagebreak-2.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/pagebreak-3.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/pagebreak.typ create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/qr-codes.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/qr-codes.typ create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/solutions.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/solutions.typ create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/styles.svg create mode 100644 packages/preview/exercise-bank/0.6.1/gallery/styles.typ create mode 100644 packages/preview/exercise-bank/0.6.1/lib.typ create mode 100644 packages/preview/exercise-bank/0.6.1/typst.toml diff --git a/packages/preview/exercise-bank/0.6.1/.gitignore b/packages/preview/exercise-bank/0.6.1/.gitignore new file mode 100644 index 0000000000..08f902dcde --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/.gitignore @@ -0,0 +1,8 @@ +# PDF files +*.pdf +# ...but keep the manual PDF tracked so the README permalink resolves +# (it is still excluded from the published package via typst.toml `exclude`) +!docs/manual.pdf + +# Typst artifacts +*.typ.aux diff --git a/packages/preview/exercise-bank/0.6.1/LICENSE b/packages/preview/exercise-bank/0.6.1/LICENSE new file mode 100644 index 0000000000..7081613f31 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/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.6.1/README.md b/packages/preview/exercise-bank/0.6.1/README.md new file mode 100644 index 0000000000..89f0c4ef7e --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/README.md @@ -0,0 +1,1062 @@ +# exercise-bank + +[![exercise-bank on Typst Universe](https://img.shields.io/badge/Typst_Universe-v._0.6.1-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/a14876e91956969deba92fa152c34c777a1204ba/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 | + +## 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 +- **Difficulty levels** - Encode up to 5 (or more) difficulty levels as badge colors, stars, or symbols +- **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 +- **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.6.1": exo + +#exo( + exercise: [ + Solve the equation $2x + 5 = 13$. + ] +) +``` + +## Basic Usage + +### Simple Exercise + +```typst +#import "@preview/exercise-bank:0.6.1": exo + +#exo( + exercise: [ + Calculate $3 + 4 times 2$. + ] +) +``` + +### Exercise with Solution + +```typst +#import "@preview/exercise-bank:0.6.1": exo + +#exo( + exercise: [ + Calculate $3 + 4 times 2$. + ], + solution: [ + $3 + 4 times 2 = 3 + 8 = 11$ + ], +) +``` + +### Multiple Exercises + +```typst +#import "@preview/exercise-bank:0.6.1": 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.6.1": 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.6.1": 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.6.1": 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.6.1": 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.6.1": 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.6.1": 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.6.1": 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.6.1": 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) + +### 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.6.1": 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.6.1": 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.6.1": 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.6.1": 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.6.1": 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.6.1": 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.6.1": 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.6.1": 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.6.1": 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.6.1": 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.6.1": 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. + +#### 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. + +### Counter Reset Options + +Control when exercise numbering resets: + +```typst +#import "@preview/exercise-bank:0.6.1": 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 `"."`). + +`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): from beautitled 0.3.0 the native heading counter is kept in sync, so `number-prefix: "heading"` works out of the box (with earlier versions, use `number-prefix: chapter-counter` with beautitled's exported counter; same with `enable-parts: true`, where the first heading level is the part). 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.6.1": 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.6.1": 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. + +## Utility Functions + +### Reset Counter + +```typst +#import "@preview/exercise-bank:0.6.1": exo-reset-counter + +#exo-reset-counter() // Reset exercise numbering to 0 +``` + +### Clear Registry + +```typst +#import "@preview/exercise-bank:0.6.1": exo-clear-registry + +#exo-clear-registry() // Clear all registered exercises +``` + +### Count Exercises + +```typst +#import "@preview/exercise-bank:0.6.1": 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 | +| `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 | +| `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-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/counter/function | none | "heading" (level-1 heading number), a custom counter, or a function () => value | +| `number-separator` | string | "." | Separator for chapter-prefixed numbers | +| `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-color` | color | black | Color for exercise badges | +| `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) | + +## Complete Example + +```typst +#import "@preview/exercise-bank:0.6.1": * + +// 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.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.6.1/gallery/bank.svg b/packages/preview/exercise-bank/0.6.1/gallery/bank.svg new file mode 100644 index 0000000000..8fd254b654 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/bank.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/bank.typ b/packages/preview/exercise-bank/0.6.1/gallery/bank.typ new file mode 100644 index 0000000000..044f777f72 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/bank.typ @@ -0,0 +1,33 @@ +#import "@preview/exercise-bank:0.6.1": 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.6.1/gallery/basic.svg b/packages/preview/exercise-bank/0.6.1/gallery/basic.svg new file mode 100644 index 0000000000..70501eb15c --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/basic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/basic.typ b/packages/preview/exercise-bank/0.6.1/gallery/basic.typ new file mode 100644 index 0000000000..3ecab57500 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/basic.typ @@ -0,0 +1,17 @@ +#import "@preview/exercise-bank:0.6.1": 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.6.1/gallery/competencies.svg b/packages/preview/exercise-bank/0.6.1/gallery/competencies.svg new file mode 100644 index 0000000000..5da589a0fd --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/competencies.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/competencies.typ b/packages/preview/exercise-bank/0.6.1/gallery/competencies.typ new file mode 100644 index 0000000000..d291e0e53b --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/competencies.typ @@ -0,0 +1,26 @@ +#import "@preview/exercise-bank:0.6.1": 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.6.1/gallery/corrections-only.svg b/packages/preview/exercise-bank/0.6.1/gallery/corrections-only.svg new file mode 100644 index 0000000000..a8ea808063 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/corrections-only.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/corrections-only.typ b/packages/preview/exercise-bank/0.6.1/gallery/corrections-only.typ new file mode 100644 index 0000000000..9c9798cfba --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/corrections-only.typ @@ -0,0 +1,49 @@ +#import "@preview/exercise-bank:0.6.1": 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.6.1/gallery/corrections.svg b/packages/preview/exercise-bank/0.6.1/gallery/corrections.svg new file mode 100644 index 0000000000..b1f73e0ecd --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/corrections.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/corrections.typ b/packages/preview/exercise-bank/0.6.1/gallery/corrections.typ new file mode 100644 index 0000000000..86754798d0 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/corrections.typ @@ -0,0 +1,50 @@ +#import "@preview/exercise-bank:0.6.1": 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.6.1/gallery/difficulty.svg b/packages/preview/exercise-bank/0.6.1/gallery/difficulty.svg new file mode 100644 index 0000000000..6b0ca01aec --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/difficulty.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/difficulty.typ b/packages/preview/exercise-bank/0.6.1/gallery/difficulty.typ new file mode 100644 index 0000000000..77aee09867 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/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.6.1": * + +#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.6.1/gallery/draft-mode-1.svg b/packages/preview/exercise-bank/0.6.1/gallery/draft-mode-1.svg new file mode 100644 index 0000000000..16e44e7973 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/draft-mode-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/draft-mode-2.svg b/packages/preview/exercise-bank/0.6.1/gallery/draft-mode-2.svg new file mode 100644 index 0000000000..32408ea813 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/draft-mode-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/draft-mode-3.svg b/packages/preview/exercise-bank/0.6.1/gallery/draft-mode-3.svg new file mode 100644 index 0000000000..444e9a1920 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/draft-mode-3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/draft-mode.typ b/packages/preview/exercise-bank/0.6.1/gallery/draft-mode.typ new file mode 100644 index 0000000000..8da2cd1eae --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/draft-mode.typ @@ -0,0 +1,80 @@ +#import "@preview/exercise-bank:0.6.1": * + +#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.6.1/gallery/end-chapter.svg b/packages/preview/exercise-bank/0.6.1/gallery/end-chapter.svg new file mode 100644 index 0000000000..e7ec4a9f69 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/end-chapter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/end-chapter.typ b/packages/preview/exercise-bank/0.6.1/gallery/end-chapter.typ new file mode 100644 index 0000000000..88bd9e1377 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/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.6.1": * + +#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.6.1/gallery/end-section.svg b/packages/preview/exercise-bank/0.6.1/gallery/end-section.svg new file mode 100644 index 0000000000..3d689c2b4e --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/end-section.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/end-section.typ b/packages/preview/exercise-bank/0.6.1/gallery/end-section.typ new file mode 100644 index 0000000000..f86c0d1236 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/end-section.typ @@ -0,0 +1,27 @@ +#import "@preview/exercise-bank:0.6.1": 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.6.1/gallery/filtering.svg b/packages/preview/exercise-bank/0.6.1/gallery/filtering.svg new file mode 100644 index 0000000000..21d67bb0f9 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/filtering.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/filtering.typ b/packages/preview/exercise-bank/0.6.1/gallery/filtering.typ new file mode 100644 index 0000000000..6d7fbb2685 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/filtering.typ @@ -0,0 +1,47 @@ +#import "@preview/exercise-bank:0.6.1": 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.6.1/gallery/inline-solutions.svg b/packages/preview/exercise-bank/0.6.1/gallery/inline-solutions.svg new file mode 100644 index 0000000000..8316210521 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/inline-solutions.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/inline-solutions.typ b/packages/preview/exercise-bank/0.6.1/gallery/inline-solutions.typ new file mode 100644 index 0000000000..17e5b95db2 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/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.6.1": * + +#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.6.1/gallery/linked-solutions.svg b/packages/preview/exercise-bank/0.6.1/gallery/linked-solutions.svg new file mode 100644 index 0000000000..a588ce17a3 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/linked-solutions.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/linked-solutions.typ b/packages/preview/exercise-bank/0.6.1/gallery/linked-solutions.typ new file mode 100644 index 0000000000..1f6edb1096 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/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.6.1": * + +#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.6.1/gallery/markers.svg b/packages/preview/exercise-bank/0.6.1/gallery/markers.svg new file mode 100644 index 0000000000..f9834e869b --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/markers.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/markers.typ b/packages/preview/exercise-bank/0.6.1/gallery/markers.typ new file mode 100644 index 0000000000..311122aa2e --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/markers.typ @@ -0,0 +1,53 @@ +#import "@preview/exercise-bank:0.6.1": * + +#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.6.1/gallery/mixed-display.svg b/packages/preview/exercise-bank/0.6.1/gallery/mixed-display.svg new file mode 100644 index 0000000000..674543444c --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/mixed-display.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/mixed-display.typ b/packages/preview/exercise-bank/0.6.1/gallery/mixed-display.typ new file mode 100644 index 0000000000..824e9319a1 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/mixed-display.typ @@ -0,0 +1,56 @@ +#import "@preview/exercise-bank:0.6.1": 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.6.1/gallery/pagebreak-1.svg b/packages/preview/exercise-bank/0.6.1/gallery/pagebreak-1.svg new file mode 100644 index 0000000000..382f3bc81a --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/pagebreak-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/pagebreak-2.svg b/packages/preview/exercise-bank/0.6.1/gallery/pagebreak-2.svg new file mode 100644 index 0000000000..3b12f59755 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/pagebreak-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/pagebreak-3.svg b/packages/preview/exercise-bank/0.6.1/gallery/pagebreak-3.svg new file mode 100644 index 0000000000..22a3a1fd21 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/pagebreak-3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/pagebreak.typ b/packages/preview/exercise-bank/0.6.1/gallery/pagebreak.typ new file mode 100644 index 0000000000..a2a21b2653 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/pagebreak.typ @@ -0,0 +1,29 @@ +#import "@preview/exercise-bank:0.6.1": 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.6.1/gallery/qr-codes.svg b/packages/preview/exercise-bank/0.6.1/gallery/qr-codes.svg new file mode 100644 index 0000000000..533976a5d8 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/qr-codes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/qr-codes.typ b/packages/preview/exercise-bank/0.6.1/gallery/qr-codes.typ new file mode 100644 index 0000000000..3dc55564c5 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/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.6.1": * + +#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.6.1/gallery/solutions.svg b/packages/preview/exercise-bank/0.6.1/gallery/solutions.svg new file mode 100644 index 0000000000..b8cf5777ef --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/solutions.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/solutions.typ b/packages/preview/exercise-bank/0.6.1/gallery/solutions.typ new file mode 100644 index 0000000000..d779ef8c94 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/solutions.typ @@ -0,0 +1,36 @@ +#import "@preview/exercise-bank:0.6.1": 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.6.1/gallery/styles.svg b/packages/preview/exercise-bank/0.6.1/gallery/styles.svg new file mode 100644 index 0000000000..ac41c25cd3 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/styles.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/preview/exercise-bank/0.6.1/gallery/styles.typ b/packages/preview/exercise-bank/0.6.1/gallery/styles.typ new file mode 100644 index 0000000000..381ec71423 --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/gallery/styles.typ @@ -0,0 +1,45 @@ +// Visual Styles Gallery - exercise-bank +// All 12 badge styles with attractive colors + +#import "@preview/exercise-bank:0.6.1": * + +#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.6.1/lib.typ b/packages/preview/exercise-bank/0.6.1/lib.typ new file mode 100644 index 0000000000..f0c3e6385f --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/lib.typ @@ -0,0 +1,2483 @@ +// 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 + // 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-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 + "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 + // 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 ".") + // 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-color: none, // Color for exercise badges + solution-color: none, // Color for solution badges + correction-color: none, // Color for correction badges + label-font-size: none, + 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) + // 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 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-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 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 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 + }) +} + +// ============================================================================= +// 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 +// ============================================================================= + +// Displayed exercise number, optionally prefixed by a chapter number. +// number-prefix may be "heading" (current level-1 heading number), a counter +// (e.g. beautitled's chapter-counter, which bypasses counter(heading)), or a +// function () => value evaluated in context. 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 == "heading" { + counter(heading).get().first() + } else if type(prefix) == function { + prefix() + } else { + // Assume a counter (custom heading packages often keep their own) + prefix.get().first() + } + 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] + } +} + +// ============================================================================= +// 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 +} + +// Compute default margin based on badge style and longest label with 3-digit number +// Measures the configured labels so exercise/solution/correction boxes align; +// falls back to the default English/French labels +#let calc-default-margin(font-size: 12pt, style: "box", labels: auto) = { + let labels = if labels == auto { + ("Exercise", "Solution", "Correction", "Exercice", "CorrigΓ©") + } else { + labels + } + + if type(style) == function { + // Custom badge function: measure with the longest label and a 3-digit number + let max-width = 0pt + for label in labels { + let width = measure(style(label, 100, font-size, black, false)).width + if width > max-width { max-width = width } + } + max-width + 16pt + } else if style == "circled" or style == "filled-circle" { + // Circle styles only show number, size is font-size * 2 + font-size * 2 + 16pt + } else if style == "rect" or style == "filled-rect" { + // Rect styles only show the number + measure(box( + inset: (x: 6pt), + text(weight: "bold", size: font-size)[100], + )).width + 16pt + } else if style == "tag" { + // Tag has arrow, measure with longest label + let max-width = 0pt + for label in labels { + let content = box( + inset: (x: 8pt, y: 3pt), + text(weight: "bold", size: font-size)[#label 100], + ) + let width = measure(content).width + if width > max-width { max-width = width } + } + max-width + 8pt + 16pt // arrow width + padding + } else if style == "pill" { + // Pill has more padding + let max-width = 0pt + for label in labels { + let content = box( + inset: (x: 10pt, y: 4pt), + text(weight: "medium", size: font-size)[#label 100], + ) + let width = measure(content).width + if width > max-width { max-width = width } + } + max-width + 16pt + } else { + // Default box style + let max-width = 0pt + for label in labels { + let width = calc-badge-width(label, 100, font-size) + if width > max-width { max-width = width } + } + max-width + 16pt + } +} + +// ============================================================================= +// 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 +// ============================================================================= + +// Style: box (default) - Rectangle with stroke +#let badge-box(label, number, font-size, color, is-solution, label-marker: none) = { + 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 + 8pt, + stroke: 0.8pt + color, + fill: fill-color, + inset: (x: 4pt, y: 0pt), + )[ + #align(horizon)[#label-content] + ] +} + +// Style: circled - Number in a circle (no label) +#let badge-circled(label, number, font-size, color, is-solution) = { + let size = font-size * 2 + 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) = { + let size = font-size * 2 + 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) = { + 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: 12pt, + inset: (x: 10pt, y: 4pt), + )[#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) = { + let arrow-width = 8pt + let tag-height = font-size + 10pt + let fill-color = color + stack(dir: ltr, spacing: 0pt, + box( + height: tag-height, + fill: fill-color, + radius: (left: 3pt, right: 0pt), + inset: (x: 8pt, y: 3pt), + )[ + #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) = { + let fill-color = if is-solution { color.lighten(85%) } else { white } + box( + height: font-size + 8pt, + stroke: 0.8pt + color, + fill: fill-color, + inset: (x: 6pt, 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) = { + box( + height: font-size + 8pt, + fill: color, + radius: 2pt, + inset: (x: 6pt, 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) = { + if type(style) == function { + 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), label-marker) + } else if style == "filled-circle" { + badge-with-marker(badge-filled-circle(label, number, font-size, color, is-solution), label-marker) + } else if style == "rect" { + badge-with-marker(badge-rect(label, number, font-size, color, is-solution), label-marker) + } else if style == "filled-rect" { + badge-with-marker(badge-filled-rect(label, number, font-size, color, is-solution), label-marker) + } else if style == "pill" { + badge-with-marker(badge-pill(label, number, font-size, color, is-solution), label-marker) + } else if style == "tag" { + badge-with-marker(badge-tag(label, number, font-size, color, is-solution), label-marker) + } else { + // Default: box + badge-box(label, number, font-size, color, is-solution, label-marker: label-marker) + } +} + +// 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. +#let style-margin(label, number, body, font-size, color, is-solution, qr: 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 { + grid( + columns: (3.35cm, 1fr), + column-gutter: 0.55cm, + align: top, + [ + #line(length: 100%, stroke: 0.45pt + color) + #v(-0.2em) + #align(right)[ + #text(size: font-size, weight: "bold", fill: color)[#label~#number:] + ] + #if qr != none { + v(0.45em) + align(right, qr) + } + ], + [ + #line(length: 0pt, stroke: 0.45pt + white) + #v(-0.2em) + #body + ], + ) + } +} + +// Style: border-accent - Left vertical bar with inline header +#let style-border-accent(label, number, body, font-size, color, is-solution) = { + let bar-color = color + block( + stroke: (left: 3pt + bar-color), + inset: (left: 12pt, y: 8pt), + width: 100%, + )[ + #text(weight: "bold", size: font-size, fill: bar-color)[#label~#number] + #v(0.3em) + #body + ] +} + +// Style: underline - Bold header with underline +#let style-underline(label, number, body, font-size, color, is-solution) = { + let line-color = color + block(width: 100%)[ + #text(weight: "bold", size: font-size + 1pt, fill: line-color)[#label~#number] + #v(-0.3em) + #line(length: 100%, stroke: 0.8pt + line-color) + #v(0.5em) + #body + ] +} + +// Style: rounded-box - Clean rounded border around entire exercise +#let style-rounded-box(label, number, body, font-size, color, is-solution) = { + let border-color = color + block( + width: 100%, + stroke: 1.2pt + border-color, + radius: 10pt, + inset: 12pt, + )[ + #text(weight: "bold", size: font-size, fill: border-color)[#label~#number] + #v(2pt) + #body + ] +} + +// Style: header-card - Rounded box with colored header strip +#let style-header-card(label, number, body, font-size, color, is-solution) = { + let header-color = color + block( + width: 100%, + stroke: 1pt + header-color, + radius: 8pt, + clip: true, + )[ + #block( + width: 100%, + 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: 8pt, 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. +#let qr-overlay-body(qr, body) = context { + let padded = box(inset: (left: 10pt, bottom: 6pt), qr) + let size = measure(padded) + place(top + right, padded) + _taskize-wrap-zone.update((width: size.width, height: size.height)) + body + _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) = { + if style == "margin" { + style-margin(label, number, body, font-size, color, is-solution, qr: qr) + } else if qr != none { + get-fullwidth-style(style, label, number, qr-wrap-body(qr, body), font-size, color, is-solution) + } else if style == "border-accent" { + style-border-accent(label, number, body, font-size, color, is-solution) + } else if style == "underline" { + style-underline(label, number, body, font-size, color, is-solution) + } else if style == "rounded-box" { + style-rounded-box(label, number, body, font-size, color, is-solution) + } else if style == "header-card" { + style-header-card(label, number, body, font-size, color, is-solution) + } 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), + ) + 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 +) = context { + let cfg = exo-config.get() + + // 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") + + // 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 3.35cm 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: 3.35cm) + } else { + full-body = qr-attach-body(make-exo-qr(qr, cfg), full-body, cfg) + } + 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, + ) + ] + } 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, + ) + + // 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] + } + + // 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: if the badge (or QR) is + // wider than the configured margin, widen the column instead + let label-col-width = calc.max( + margin-pos + label-extra, + measure(badge-with-points).width, + if badge-sub != none { measure(badge-sub).width } else { 0pt }, + if qr-block != none { measure(qr-block).width } else { 0pt }, + ) + + // Build the label column content + let label-column = { + set text(hyphenate: false) + 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: (label-col-width, 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 + #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) = 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, + ) +} + +#let exo-correction-box(number: 1, body, exercise-id: none, show-id: false, qr: none, label-marker: 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, + ) +} + +// ============================================================================= +// 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) = { + 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, + ) + } + } 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, + ) + } + } +} + +// 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") +#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 + } + context { + let found = query(target) + if found.len() > 0 { + let page-num = counter(page).at(found.first().location()).first() + let fmt = cfg.at("page-ref-format", default: auto) + let body = 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.~#page-num] + } else { + fmt(ref-label, page-num) + } + link(target, body) + } + } +} + +// 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) = { + 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") + 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) + 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, + )) + pending + }) + } +} + +// ============================================================================= +// Main Exercise Function +// ============================================================================= + +#let exo( + exercise: none, + solution: none, + correction: none, + id: auto, + 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, + ) + + // 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) + show-content-items(items-to-show, disp-num, exercise-id, cfg) + } else if cfg.display == "ex" { + // Only show exercise + exo-box( + label: get-exercise-label(cfg, metadata), + number: disp-num, + exercise, + exercise-id: exercise-id, + 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, + ) + } 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) + + // Exercise <-> correction links: only when something is actually deferred + let link-base = none + let link-marker = 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" { + exercise-body = qr-attach-body( + make-page-ref(cfg, link-base, items-to-show, ex-badge-color), + exercise-body, cfg, + ) + } 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, + 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, + ) + + // Handle solution/correction display (per-type location) + place-content-items(items-to-show, disp-num, exercise-id, cfg, link-base: link-base) + } + } // close context +} // close function + +// ============================================================================= +// Solution Display Functions +// ============================================================================= + +// Print collected solutions/corrections. With loc: "end-section" or +// "end-chapter", only the items deferred to that location are printed +// (the others stay pending); loc: none prints everything. +#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) + + 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" { + 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, + ) + } else { + 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, + ) + } + } + } + + // Keep only the items deferred to another location + exo-pending-solutions.update(to-keep) +} + +#let exo-section-end() = { + exo-print-solutions(loc: "end-section") +} + +#let exo-chapter-end() = { + exo-print-solutions(loc: "end-chapter") +} + +// Wrap the whole document to automate end-chapter corrections: the pending +// items are printed before each new level-1 heading and at the end of the +// document, and the exercise counter resets at each chapter (per the +// counter-reset setting). +// +// #show: exo-auto-chapter +#let exo-auto-chapter(body) = 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.where(level: 1): 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") { + it + } else { + { + set text(size: base-size, weight: base-weight) + exo-chapter-end() + } + it + exo-chapter-start() + } + } + body + 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, +) = 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, + 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), + ) + } + + // Handle solution/correction display + if show-solutions and cfg.display != "ex" { + 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), + ) + show-content-items(items-to-show, exercise.number, exercise.id, cfg) + } + } + } +} + +// ============================================================================= +// 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, + 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, + ) + + // 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, + 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, + ) + + // 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) + let show-items = items-to-show.len() > 0 and (cfg.display == "sol" or (cfg.display == "both" and do-show-solution)) + + // Exercise <-> correction links: only when something is actually deferred + let link-base = none + let link-marker = 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" { + exercise-body = qr-attach-body( + make-page-ref(cfg, link-base, items-to-show, ex-badge-color), + exercise-body, cfg, + ) + } 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, + 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, + ) + } + + // 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) + } else { + place-content-items(items-to-show, disp-num, found.id, cfg, link-base: link-base) + } + } + } + + // 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, + 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, + ) + + // 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), + ) + let show-items = items-to-show.len() > 0 and (cfg.display == "sol" or do-show-solutions) + + // 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 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" { + exercise-body = qr-attach-body( + make-page-ref(cfg, link-base, items-to-show, ex-badge-color), + exercise-body, cfg, + ) + } 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, + 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, + ) + } + + // 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) + } else { + place-content-items(items-to-show, disp-num, exercise.id, cfg, link-base: link-base) + } + } + } + + // 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.6.1/typst.toml b/packages/preview/exercise-bank/0.6.1/typst.toml new file mode 100644 index 0000000000..8b61e2ecdd --- /dev/null +++ b/packages/preview/exercise-bank/0.6.1/typst.toml @@ -0,0 +1,13 @@ +[package] +name = "exercise-bank" +version = "0.6.1" +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"]