From c995bfa0c4033b27c42296c955871b3d7a60e064 Mon Sep 17 00:00:00 2001 From: Gaspard Jankowiak Date: Mon, 17 Nov 2025 08:12:57 +0100 Subject: [PATCH] import --- lib.typ | 262 +++++++++++++++++++++++++++++++++++++++++++++++++++++ typst.toml | 8 ++ 2 files changed, 270 insertions(+) create mode 100644 lib.typ create mode 100644 typst.toml diff --git a/lib.typ b/lib.typ new file mode 100644 index 0000000..d387bf6 --- /dev/null +++ b/lib.typ @@ -0,0 +1,262 @@ +/* +* Customisation +*/ + +#let lightgray = rgb("#eeeeee") + +// Default font (embedded in Typst) +#let font-text = "libertinus serif" + +#let mtext = text.with(font: font-text) + +/* +* Macros +*/ + +// Frame 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) => ( + left: none, + right: none, + top: if y > 0 { s } else { 2*s }, + bottom: 2*s, +) + +#let capitalize(string) = { + return string.replace( + regex("[A-Za-z]+('[A-Za-z]+)?"), + word => upper(word.text.first()) + lower(word.text.slice(1)), + ) +} + +#let get_L(k, cap:false) = context { + let translations = state("translations").get() + let term = translations.at(k, default:k) + if cap { + return capitalize(term) + } else { + return term + } +} + +// Formats number of points, "1 Punkt", "2 Punkte", etc. +// p: int +#let format_points = (p) => ( + context { + if p <= 1 { + [#p #get_L("point")] + } else { + [#p #get_L("points")] + } + } +) + +// Displays a list of sub-questions and the associated points +// items: array of (int, expression), where +// - int is the number of points this item is worth, +// - expression is the item to typeset. +// example: +// #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") + #c.update(0) + #table( + columns: (1fr, auto), + stroke: none, + ..for (points, statement) in items { + let pt + if points <= 1 { + pt = get_L("point_short") + } else { + pt = get_L("points_short") + } + ( + [#c.step()#v(4pt) *#context c.display("a)")* #statement], + rect(radius:1cm, inset: (x:5pt, y:3pt), stroke: gray+0.5pt)[#points #pt], + ) + } + )] + +// Displays a header for the exercise, ex. number is incremented automatically +// 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_counter = counter("exercise") + context { + let exercise_registry = state("exercise-registry").get() + exercise_counter.step() + let exercise_label = exercise_counter.display() + // 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(points)*]], + ) +)} + +#let exam( + title: "%KLAUSUR or EXAM%", + + subtitle: [%COURSE_TITLE_FIRST_LINE% \ %COURSE_TITLE_SECOND_LINE%], + header_left: "%COURSE_SHORT_TITLE%", + header_right: "%DATE%", + + course_number: "%COURSE_NUMBER%", + duration_minutes: "%DURATION%", + + instructions: none, + + language: "de", // or "en" + font-size: 12pt, + paper-size: "a4", + + body +) = { + +/* +* Setup +*/ + +// Paper and text size +set page(paper: paper-size) +set text(size: font-size, lang: language, hyphenate: true) + +/* +* Localization +*/ + +let translations = none +if language == "en" { + translations = ( + course_number: "Course number", + exam_duration: "Exam duration", + student_name: "Name", + student_number: "Student number", + trainer_name: "Trainer", + exercise: "Exercise", + point: "point", + points: "points", + point_short: "pt", + points_short: "pts", + total_points: "Total", + minutes: "minutes", + ) +} else if language == "de" { + translations = ( + course_number: "LV-Nummer", + exam_duration: "Arbeitszeit", + student_name: "Name", + student_number: "Matrikelnr.", + trainer_name: "Übungsleiter", + exercise: "Beispiel", + point: "Punkt", + points: "Punkte", + point_short: "P.", + points_short: "P.", + total_points: "Gesamt", + minutes: "Minuten", + ) +} + +state("translations").update(translations) + +// State and counter for exercises +context { let exercise_registry = state("exercise-registry", ()) } + +// Displays an overview of the exam as a table, with +// a row per exercise, the corresponding points and space to write the mark. +// The +let exercise_overview_table() = context { + let entries = state("exercise-registry").final() + let total_points = entries.fold(0, (sum, entry) => sum + entry.at(2)) + align(center)[ + #set text(size: 14pt) + #table( + columns: (auto, auto), + stroke: frame(0.5pt), + fill: none, + inset: 10pt, + align: horizon, + table.header[#get_L("exercise")][#get_L("points", cap: true)], + table.hline(stroke: 1pt), + ..for entry in entries { + ( + [#entry.at(0)], + [#h(2cm) / #entry.at(2)], + ) + }, + table.hline(stroke: 1pt), + [*#get_L("total_points")*], [#h(2.2cm) / *#total_points*] + ) + ] +} + +// Header and footer +set page( + header: [ + #header_left + #h(1fr) + _#(header_right)_ + ], + footer: [#align(right)[#context(counter(page).display("1 / 1", both:true))]], +) + +// Title page + +v(1fr) +align(center)[= #title] +v(0.5cm) +align(center)[== #subtitle] +v(1fr) + +[*#get_L("course_number")*: #course_number* \ #get_L("exam_duration"): #duration_minutes #get_L("minutes")*] + +v(0.5cm) + +table( + fill: (lightgray, none), + stroke: frame(0.5pt), + columns: (auto, 1fr), + inset: 10pt, + align: horizon, + [#get_L("student_name")], [], + [#get_L("student_number")], [], + [#get_L("trainer_name")], [], // comment this line if unneeded +) + +v(0.5cm) + +if (instructions != none) { + [ #instructions ] +} else { + if language == "de" [ +- Es sind keine Unterlagen und auch keine Taschenrechner erlaubt. +- Alle Rechenschritte (inklusive Zwischenresultate und Lösungswege) sind anzugeben und alle Antworten genau zu begründen bzw. zu beweisen! +- Schreiben Sie auf jedes _lose_ Blatt Ihren Namen und Ihre Matrikelnummer! +- Nicht mit roter Farbe schreiben. + ] + if language == "en" [ +- Course material and calculators are not allowed. +- Write down all the steps, including intermediate results. All answers must be substantiated! +- Do not write in red. + ] +} + +v(1fr) + +// exercise_overview_table() + +v(1fr) + +pagebreak() + +body +} diff --git a/typst.toml b/typst.toml new file mode 100644 index 0000000..bdc9dac --- /dev/null +++ b/typst.toml @@ -0,0 +1,8 @@ +[package] +name = "exam" +version = "0.1.0" +entrypoint = "lib.typ" +authors = ["Gaspard Jankowiak"] +license = "MIT" +description = "Exams for NAWI Graz." +compiler = "0.14.0"