diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6f7cfff --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Gaspard Jankowiak + +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/README.md b/README.md new file mode 100644 index 0000000..e07775c --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +# 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`](https://github.com/sjfhsjfh/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.3.0.tar.gz | tar zx --xform 's/typst-exam/0.3.0/' +``` + +## Quickstart (CLI) + +This will create an `exam` directory in the current directory, +containing the example below. + +```sh +typst init @local/exam exam +``` + +Tested typst version: `0.14.0`. + +## Usage + +Writing an exam is now easy (see [API](#api) for all options): + +```typ +#import "@local/exam:0.3.0": exam, exercise, exercise-items, mtext, horiz-only + +#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: horiz-only(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/screenshot-example.png b/screenshot-example.png new file mode 100644 index 0000000..d9784d9 Binary files /dev/null and b/screenshot-example.png differ diff --git a/lib.typ b/src/lib.typ similarity index 77% rename from lib.typ rename to src/lib.typ index c29a740..cfc1db8 100644 --- a/lib.typ +++ b/src/lib.typ @@ -2,8 +2,6 @@ * Customisation */ -#let lightgray = rgb("#eeeeee") - // Default font (embedded in Typst) #let font-text = "libertinus serif" @@ -13,9 +11,9 @@ * Macros */ -// Frame for table, no vertical lines, top and bottom are bold +// stroke helper function for table, no vertical lines, top and bottom are bold // s: stroke, to specify e.g. the width and color of strokes -#let frame(s) = (x, y) => ( +#let horiz-only(s) = (x, y) => ( left: none, right: none, top: if y > 0 { s } else { 2*s }, @@ -57,13 +55,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 +76,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 +101,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, + block(radius: 1cm, stroke: none, fill: luma(state("fill-luma").get()), 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 +147,10 @@ font-size: 12pt, paper-size: "a4", + exercise-numbering: "1", + + fill-luma: 200, + body ) = { @@ -155,6 +162,9 @@ set page(paper: paper-size) set text(size: font-size, lang: language, hyphenate: true) +state("exercise-numbering").update(exercise-numbering) +state("fill-luma").update(fill-luma) + /* * Localization */ @@ -162,7 +172,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 +190,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.", @@ -215,7 +225,7 @@ let exercise_overview_table() = context { columns.push(1fr) [== #get_L("points_obtained")] set text(size: 14pt) - block(radius: 0.3cm, stroke: none, fill: lightgray, inset: (x:0pt, y:4pt), + block(radius: 0.3cm, stroke: none, fill: luma(state("fill-luma").get()), inset: (x:0pt, y:4pt), table( columns: columns, stroke: none, @@ -260,7 +270,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,31 +281,33 @@ 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) -block(radius: 0.2cm, fill: lightgray, inset: 3pt, clip: true, table( - fill: lightgray, +context{ +block(radius: 0.2cm, fill: luma(state("fill-luma").get()), inset: 3pt, clip: true, table( + fill: luma(state("fill-luma").get()), stroke: none, columns: (auto, 1fr), gutter: 3pt, 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)) } )) +} v(0.5cm) diff --git a/template/exam.typ b/template/exam.typ new file mode 100644 index 0000000..2a160a4 --- /dev/null +++ b/template/exam.typ @@ -0,0 +1,88 @@ +#import "@local/exam:0.3.0": exam, exercise, exercise-items, horiz-only, mtext + +// General configuration +#show: exam.with( + title: "Exam", + + course-title: [Abstract Binary Computation & Elegant Finite Graphs], + + // Top left header + institution: [Super University], + + // Top right header + course-short-title: "ABC & EFG", + date: "1. January 1970", + + // Exam details, appears below the title + course-code: "π", + duration-minutes: "90", + + // Configure personal information fields + ask-trainer-name: false, + ask-group: true, + ask-student-number: false, + + // Configure language, either "de" or "en" + language: "en", + + // custom instructions + // instructions: none, + + // change font-size + // font-size: 12pt, + + // custom paper size + // paper-size: "a4", + + // custom numbering of exercises + // exercise-numbering: "1", + + // custom filling lightness for headings + // fill-luma: 150, +) + +// First exercise +#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? + +// Second exercise +#exercise("Properties of functions", 3) + +Let $f : (0, +infinity) → (0, +infinity)$ with $f (x) = e^(-x)$. + +// Several sub-questions +#exercise-items(( + (1, [Is $f$ injective?]), // The first argument is the number + // of points the item is worth + (1, [Is $f$ surjective?]), + (2, [Is $f$ bijective?]), +)) + +// Third exercise +#exercise("Logical operators", 3.14) + +Consider the following truthtable: + +#align(center, table( + stroke: horiz-only(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], +)) + +// More sub-questions +// override-points to false, so that the exercise total points are not recomputed. +#exercise-items(override-points: false, numbering: "i)", ( + (1, [Is the truthtable correct?]), + (1, [If not, fix it.]), +)) + diff --git a/thumbnail.png b/thumbnail.png new file mode 100644 index 0000000..e37c37c Binary files /dev/null and b/thumbnail.png differ diff --git a/typst.toml b/typst.toml index bdc9dac..a1731b4 100644 --- a/typst.toml +++ b/typst.toml @@ -1,8 +1,14 @@ [package] name = "exam" -version = "0.1.0" -entrypoint = "lib.typ" +version = "0.3.0" +entrypoint = "src/lib.typ" authors = ["Gaspard Jankowiak"] license = "MIT" description = "Exams for NAWI Graz." compiler = "0.14.0" +categories = ["layout", "office"] + +[template] +path = "template" +entrypoint = "exam.typ" +thumbnail = "thumbnail.png"