Compare commits

...

9 commits

Author SHA1 Message Date
24a29b2d89 [README] update 2025-11-18 10:12:00 +01:00
880b534b35 [README] update screenshot 2025-11-18 10:12:00 +01:00
07d8825d14 [README] update 2025-11-18 10:12:00 +01:00
0ba530f4e3 [README] add link to Typst.app template 2025-11-18 10:12:00 +01:00
1469d538df [README] update 2025-11-18 10:11:50 +01:00
Gaspard Jankowiak
6114e7b036 add types for code blocks in README 2025-11-17 20:59:23 +01:00
Gaspard Jankowiak
c81d8f65b2 enable customizing exercise numbering 2025-11-17 20:56:12 +01:00
Gaspard Jankowiak
921082f61d refactor 2025-11-17 20:47:05 +01:00
4c496fc7a9 add README 2025-11-17 14:09:49 +01:00
3 changed files with 184 additions and 32 deletions

143
README.md Normal file
View file

@ -0,0 +1,143 @@
# The `exam` package
This is a simple package to typeset exams. With it, you can define exercises,
and items, each with a number of points.
A summary is then printed on the front page.
## Installation (Linux only)
#### Using `typship`
```bash
typship download -n local https://imsc.uni-graz.at/git/gjankowiak/typst-exam/
```
#### Manually
```bash
mkdir -p ~/.local/share/typst/packages/local/exam
cd ~/.local/share/typst/packages/local/exam
curl https://imsc.uni-graz.at/git/gjankowiak/typst-exam/archive/v0.1.0.tar.gz | tar zx --xform 's/typst-exam/0.1.0/'
```
## Usage
Writing an exam is now easy (see [API](#api) for all options):
```typ
#import "@local/exam:0.1.0": exam, exercise, exercise-items, mtext
#show: exam.with(
title: "Exam",
course-title: [Abstract Binary Computation & Elegant Finite Graphs],
institution: [Super University],
date: "1. January 1970",
course-short-title: "ABC & EFG",
course-code: "π",
duration-minutes: "90",
ask-trainer-name: false,
ask-group: true,
language: "en",
)
```
### Defining exercises and items
A new exercise can be started using `#exercise("title", nb_points)`, for example:
```typ
#exercise("Relations and their properties", 2)
Consider the relation $R subset NN^2$, defined as follows:
$ (x, y) in R #h(0.5cm) <==> #h(0.5cm) x + y #mtext[is odd]. $
Is $R$ reflexive? transitive? symmetric? antisymmetric?
```
On can also defined (sub-)items for the exercise using `#exercise-items(override_points:true, numbering:"a)", items)`,
where `items` is an array of `(nb_points, statement)`. By default, the number of points the exercise
is worth is recomputed as the sum of points for all items. This behaviour can be turned off by
setting `override_points: false`.
```typ
#exercise("Properties of functions", 3)
Let $f : (0, +infinity) → (0, +infinity)$ with $f (x) = e^(-x)$.
#exercise-items((
(1, [Is $f$ injective?]),
(1, [Is $f$ surjective?]),
(1, [Is $f$ bijective?]),
))
#exercise("Logical operators", 3.14)
Consider the following truthtable:
#align(center, table(
stroke: frame(0.5pt),
columns: (auto, auto, auto),
align: center,
[$A$], [$B$], [$A or B$],
[1], [1], [1],
[1], [0], [1],
[0], [1], [1],
[0], [0], [1]
))
#exercise-items(override-points: false, numbering: "i)", (
(1, [Is the truthtable correct?]),
(1, [If not, fix it.]),
))
```
All together, this should output something like:
![screenshot](screenshot-example.png)
You can also [play with it](https://typst.app/project/rrQbGYoQ3pePdfSMl0tmv4) on the Typst Playground (you might need an account).
### Utilities
These are used in the example above.
- `mtext(str)` to typeset text within math mode using the default text font.
- `frame(stroke_width)` provides `stroke` for use in a `table`, horizontal lines only,
top and bottom lines are bold.
### API
```typ
#exam(
title: "%KLAUSUR or EXAM%",
course-title: [%COURSE_TITLE_FIRST_LINE% \ %COURSE_TITLE_SECOND_LINE%],
course-short-title: "%course-short-title%",
course-code: "%course-code%",
date: "%DATE%",
institution: smallcaps("NAWI Graz"),
duration-minutes: "%DURATION%",
ask-student-number: false,
ask-trainer-name: false,
ask-group: true,
instructions: none,
language: "de", // or "en"
font-size: 12pt,
paper-size: "a4",
exercise-numbering: "1",
)
```

73
lib.typ
View file

@ -57,13 +57,13 @@
// - int is the number of points this item is worth,
// - expression is the item to typeset.
// example:
// #exercise_items((
// #exercise-items((
// (1, [Untersuchen Sie, ob $f$ injektiv ist.]),
// (1, [Untersuchen Sie, ob $f$ surjektiv ist.]),
// (1, [Geben Sie im Falle ihrer Existenz die Umkehrfunktion $f^(1)$ an.])
//))
#let exercise_items(items) = block[
#let c = counter("exercise_items")
#let exercise-items(override-points: true, numbering: "a)", items) = block[
#let c = counter("exercise-items")
#let exercise_points = 0
#c.update(0)
#table(
@ -78,21 +78,24 @@
pt = get_L("points_short")
}
(
[#c.step()#v(4pt) *#context c.display("a)")* #statement],
[#c.step()#v(4pt) *#context c.display(numbering)* #statement],
rect(radius:1cm, inset: (x:5pt, y:3pt), stroke: gray+0.5pt)[#points #pt],
)
}
)
#context {
state("exercise-registry").update( arr => {
if (arr.len() == 0) {
arr
} else {
let new_arr = arr.slice(0, arr.len()-1)
new_arr.push((arr.last().at(0), arr.last().at(1), exercise_points))
new_arr
}
})
#if override-points {
context {
state("exercise-registry").update( arr => {
if (arr.len() == 0) {
arr
} else {
let new_arr = arr.slice(0, arr.len()-1)
new_arr.push((arr.last().at(0), arr.last().at(1), exercise_points))
new_arr
}
})
}
}
]
@ -100,43 +103,45 @@
// Used to compute the overview table (see below)
// title: string, title of the exercise
// points: int, the number of points the exercise is worth
#let exercise_header(title, points) = {
#let exercise(title, points) = {
// state("exercise_points").update(points)
let exercise_counter = counter("exercise")
exercise_counter.step()
context {
let current_exercise = exercise_counter.get().at(0)
let exercise-numbering = state("exercise-numbering").get()
let exercise_registry = state("exercise-registry")
let final_points = 0
if (exercise_registry.final().len() >= current_exercise) {
final_points = exercise_registry.final().at(exercise_counter.get().at(0)-1).at(2)
}
let exercise_label = exercise_counter.display()
let exercise_label = exercise_counter.display(exercise-numbering)
exercise_registry.update(entries => entries + ((exercise_label, title, points),))
block(radius: 1cm, stroke: none, fill: lightgray, inset: 1pt,
table(
columns: (auto, 1fr, auto),
align: (left, center, right),
stroke: none,
[#rect(radius:1cm, outset: 4pt, inset: (x:2pt, y:0pt), fill: white)[*#get_L("exercise") #context{exercise_counter.display()}*]], [#title], [#rect(radius:1cm, outset: 4pt, inset: (x:2pt, y:0pt), fill: white)[*#format_points(final_points)*]],
[#rect(radius:1cm, outset: 4pt, inset: (x:2pt, y:0pt), fill: white)[*#get_L("exercise") #context{exercise_counter.display(exercise-numbering)}*]], [#title], [#rect(radius:1cm, outset: 4pt, inset: (x:2pt, y:0pt), fill: white)[*#format_points(final_points)*]],
)
)}}
#let exam(
title: "%KLAUSUR or EXAM%",
course_title: [%COURSE_TITLE_FIRST_LINE% \ %COURSE_TITLE_SECOND_LINE%],
course_short_title: "%COURSE_SHORT_TITLE%",
course-title: [%COURSE_TITLE_FIRST_LINE% \ %COURSE_TITLE_SECOND_LINE%],
course-short-title: "%course-short-title%",
course-code: "%course-code%",
date: "%DATE%",
institution: smallcaps("NAWI Graz"),
course_number: "%COURSE_NUMBER%",
duration_minutes: "%DURATION%",
duration-minutes: "%DURATION%",
ask_student_number: false,
ask_trainer_name: false,
ask_group: true,
ask-student-number: false,
ask-trainer-name: false,
ask-group: true,
instructions: none,
@ -144,6 +149,8 @@
font-size: 12pt,
paper-size: "a4",
exercise-numbering: "1",
body
) = {
@ -155,6 +162,8 @@
set page(paper: paper-size)
set text(size: font-size, lang: language, hyphenate: true)
state("exercise-numbering").update(exercise-numbering)
/*
* Localization
*/
@ -162,7 +171,7 @@ set text(size: font-size, lang: language, hyphenate: true)
let translations = none
if language == "en" {
translations = (
course_number: "Course number",
course-code: "Course code",
exam_duration: "Exam duration",
student_name: "Name",
student_number: "Student number",
@ -180,7 +189,7 @@ if language == "en" {
)
} else if language == "de" {
translations = (
course_number: "LV-Nummer",
course-code: "LV-Nummer",
exam_duration: "Arbeitszeit",
student_name: "Name",
student_number: "Matrikelnr.",
@ -260,7 +269,7 @@ set page(
inset: 0pt,
align: (x, y) => { if x < 2 { left+bottom } else { right }},
columns: (auto, 1fr, auto),
[#institution], [], [#course_short_title \ _#(date)_],
[#institution], [], [#course-short-title \ _#(date)_],
)
],
footer: [#align(right)[#context(counter(page).display("1 / 1", both:true))]],
@ -271,10 +280,10 @@ set page(
v(1fr)
align(center)[= #title]
v(0.5cm)
align(center)[== #course_title]
align(center)[== #course-title]
v(1fr)
[*#get_L("course_number")*: #course_number* \ #get_L("exam_duration"): #duration_minutes #get_L("minutes")*]
[*#get_L("course-code")*: #course-code \ *#get_L("exam_duration"): #duration-minutes #get_L("minutes")*]
v(0.5cm)
@ -286,13 +295,13 @@ block(radius: 0.2cm, fill: lightgray, inset: 3pt, clip: true, table(
inset: (left:7pt, right:0pt, y:0pt),
align: horizon,
[#get_L("student_name")], rect(fill: white, width: 100%, radius: 0.2cm),
.. if ask_student_number {
.. if ask-student-number {
([#get_L("student_number")], rect(fill: white, width: 100%, radius: 0.2cm))
},
..if ask_trainer_name {
..if ask-trainer-name {
([#get_L("trainer_name")], rect(fill: white, width: 100%, radius: 0.2cm))
},
..if ask_group {
..if ask-group {
([#get_L("group")], rect(fill: white, width: 100%, radius: 0.2cm))
}
))

BIN
screenshot-example.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB