diff --git a/README.md b/README.md new file mode 100644 index 0000000..a546bee --- /dev/null +++ b/README.md @@ -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", +) +``` diff --git a/lib.typ b/lib.typ index c29a740..a2004c6 100644 --- a/lib.typ +++ b/lib.typ @@ -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)) } )) diff --git a/screenshot-example.png b/screenshot-example.png new file mode 100644 index 0000000..d9784d9 Binary files /dev/null and b/screenshot-example.png differ