Typesetting exams with points inside the task

I’m trying to set up a template in typst for creating lecture exercises.

At the end of the day, I’m aiming for following user experience:

#import "../template.typ": *
#exercise(
  [Exercise Title]
)[
Here are the tasks:

  + Number of possible values that one bit can hold. #points(1)
    #solution[ 2  ]
  + Number of possible values that two bits can hold. #points(1)
    #solution[ ...  ]

]

I wrote following template file to calculate the number of points for each task:

Summary
#let show-solutions = state("show-solutions", false)
#let exercise-counter = counter("exercise")
#let total-points = state("total-points", 0)
#let current-exercise-points = state("current-exercise-points", 0)

#let exercise(title, body) = context {
  let strings = strings-de

  set enum(numbering: "a)")
  
  // Increment exercise counter
  exercise-counter.step()
  let ex-num = exercise-counter.get().first()
  
  // Reset and calculate points by processing body
  current-exercise-points.update(0)
  
  // Process body to count points
  let _ = body
  
  // Get the points that were accumulated
  let ex-points = current-exercise-points.get()
  
  // Add to total points
  total-points.update(t => t + ex-points)
  
  v(0.7cm)
  
  let ex-display-num = ex-num + 1
  // Exercise header
  [
    #text(size: 14pt, weight: "bold")[
      #ex-display-num. #strings.task: #title (#ex-points #if ex-points == 1 { strings.point } else { strings.points })
    ]
  ]
  
  v(0.3cm)
  
  body
}

#let points(n) = {
  let strings = strings-de
  // Add to current exercise points
  current-exercise-points.update(p => p + n)
  [(#n #if n == 1 { strings.point } else { strings.points })#h(0.2em)]
}

#let solution(body) = context {
  let strings = strings-de
  let show-sol = show-solutions.get()
  
  if show-sol {
    v(0.3cm)
    [*#strings.solution:*]
    linebreak()
    body
  }
}

#let point-summary() = context {
  let strings = strings-de
  let total = total-points.final()
  
  v(0.5cm)
  text(weight: "bold")[
    Total *#total #if total == 1 { strings.point } else { strings.points }*.
  ]
  v(0.5cm)
}

However, I do not really see any way how I could “refer forward” to calculate the number of points in the task and write it into the headline of the task. Now the output is off-setted by one task.

Any help or suggestion would be highly appreciated! Thank you!

Your code does not compile. Can you make a minimum working example?

Hi
here is a compiling example:

#let show-solutions = state("show-solutions", false)
#let exercise-counter = counter("exercise")
#let total-points = state("total-points", 0)
#let current-exercise-points = state("current-exercise-points", 0)

#let strings-de = (
  exercise: "Übung",
  task: "Aufgabe",
  solution: "Lösung",
  points: "Punkte",
  point: "Punkt",
  submission: "Abgabe",
  discussion: "Besprechung",
  sample-solution: "Musterlösung",
)

#let exercise(title, body) = context {
  let strings = strings-de

  set enum(numbering: "a)")
  
  // Increment exercise counter
  exercise-counter.step()
  let ex-num = exercise-counter.get().first()
  
  // Reset and calculate points by processing body
  current-exercise-points.update(0)
  
  // Process body to count points
  let _ = body
  
  // Get the points that were accumulated
  let ex-points = current-exercise-points.get()
  
  // Add to total points
  total-points.update(t => t + ex-points)
  
  v(0.7cm)
  
  let ex-display-num = ex-num + 1
  // Exercise header
  [
    #text(size: 14pt, weight: "bold")[
      #ex-display-num. #strings.task: #title (#ex-points #if ex-points == 1 { strings.point } else { strings.points })
    ]
  ]
  
  v(0.3cm)
  
  body
}

#let points(n) = {
  let strings = strings-de
  // Add to current exercise points
  current-exercise-points.update(p => p + n)
  [(#n #if n == 1 { strings.point } else { strings.points })#h(0.2em)]
}

#let solution(body) = context {
  let strings = strings-de
  let show-sol = show-solutions.get()
  
  if show-sol {
    v(0.3cm)
    [*#strings.solution:*]
    linebreak()
    body
  }
}


#let point-summary() = context {
  let strings = strings-de
  let total = total-points.final()
  
  v(0.5cm)
  text(weight: "bold")[
    In dieser Übung können Sie *#total #if total == 1 { strings.point } else { strings.points }* sammeln.
  ]
  v(0.5cm)
}


#point-summary()

#exercise(
  [Zahlenbereiche]
)[
  + Welches ist die größte Zahl, die sich mit 5 Bit (vorzeichenlose Darstellung) darstellen lässt?#points(3)

    #solution[
      Die größte Zahl berechnet sich aus $2^("AnzahlBits")-1$, in unserem Fall $2^5-1=31$
    ]
]

#exercise(
  [Zahlenbereiche]
)[
  + Welches ist die größte Zahl, die sich mit 5 Bit (vorzeichenlose Darstellung) darstellen lässt?#points(1)

    #solution[
      Die größte Zahl berechnet sich aus $2^("AnzahlBits")-1$, in unserem Fall $2^5-1=31$
    ]
]

#exercise(
  [Zahlenbereiche]
)[
  + Welches ist die größte Zahl, die sich mit 5 Bit (vorzeichenlose Darstellung) darstellen lässt?#points(2)

    #solution[
      Die größte Zahl berechnet sich aus $2^("AnzahlBits")-1$, in unserem Fall $2^5-1=31$
    ]
]

Hi there @posedge_clk and welcome to the forum!
Thanks for sharing your code and providing an example that compiles.

Here is a more-MWE like version of your code showing the issue:

#let exercise-counter = counter("exercise")
#let total-points = state("total-points", 0)
#let current-exercise-points = state("current-exercise-points", 0)

#let strings-de = (
  task: "Task",
  points: "Points",
  point: "Point",
)

#let exercise(title, body) = context {
  let strings = strings-de

  set enum(numbering: "a)")

  // Increment exercise counter
  exercise-counter.step()
  let ex-num = exercise-counter.get().first()

  // Reset and calculate points by processing body
  current-exercise-points.update(0)

  // Process body to count points
  let _ = body

  // Get the points that were accumulated
  let ex-points = current-exercise-points.get()

  // Add to total points
  total-points.update(t => t + ex-points)

  let ex-display-num = ex-num + 1
  // Exercise header
  [
    #text(size: 14pt, weight: "bold")[
      #ex-display-num. #strings.task: #title (#ex-points #if ex-points == 1 { strings.point } else { strings.points })
    ]
  ]
  body
}

#let points(n) = {
  let strings = strings-de
  // Add to current exercise points
  current-exercise-points.update(p => p + n)
  [(#n #if n == 1 { strings.point } else { strings.points })]
}

#let point-summary() = context {
  let strings = strings-de
  let total = total-points.final()
  text(weight: "bold")[
    Total *#total #if total == 1 { strings.point } else { strings.points }*
  ]
}

#text(red)[Should show 10 points #sym.arrow ]
#point-summary()

#exercise(
  text(red)[Should show 3 points],
)[+ Should show 3 points? #points(3)]

#exercise(
  text(red)[Should show 7 points],
)[+ Should show 7 points? #points(7)]

Without any doubt, the solution resides in this forum post:

and has to do with your usage of context, state and update.

You will have to make a few modifications in your exercise and point-summary functions.

As a side note, you can always inspire yourself from existing exam packages that show the same kind of output.

This was a really interesting problem but I got it to work. Learned a lot.

So, the first thing I tried was using a new state per exercise and defining a points() function inside exercise that modified this state. Sadly the points function was not available in the body. So I learned that this not work:

#let exercise(body) = [
   #let points(a) = [(#a points)]
   #body
]

#exercise[
+ add 1 and 1 #points(10)
]

It fails with: Unknown variable: points

The next approach I tried was using just one running counter, a tally, that was reset to 0 at the beginning of every exercise. Then I created a unique label at the end of every exercise. My idea was the use the state atmethod to retrieve the value at these unique labels. But that didn’t work, the error I got was that the text was not locatable.

In the end I once again decide to use a different state per exercise. I use an outside state variable to hold this per exercise state. I also used a state variable to hold a dictionary of all states of all exersises.

The end result:

#let exc = counter("exercise")
#let tally = state("current-state", state("initial-state", 0))
#let all-states = state("all-states", (:))

#let points(amount) = context[
  (#amount points) // display
  #let st = tally.get()
  #st.update(c => c + amount)
]

#let exercise(title: "", body) = {
  exc.step()
  // now we're accessing the counter
  context{
    // let's create a new state
    let lbl = "Exercise: " + exc.display()
    let st = state(lbl, 0)
    // and put the new state in the tally 
    tally.update(st)
    all-states.update(dict => dict + ((lbl): st))
  
    // OK, now let's start displaying the title of our exercise:
    [*Exercise #exc.display(): #title*
      #h(1fr)
      *(#st.final() points)*
      #linebreak()
    ]
    body
    [The total amount of points is: #st.final()]
  }
}

#exercise[
+ add 2 and 2. #points(3)
+ add 3 and 3. #points(2)
]

#exercise[
+ add 6 and 7. #points(3)
+ add 5 and 9. #points(1)
]

All states: \
#context{
  for item in all-states.get().pairs() {
    [#item.at(0): #item.at(1).final() \ ]
  }
}

1 Like

See Create a dedicated section on dangers of using square brackets carelessly (multi-line) · Issue #6844 · typst/typst · GitHub.

#let points(amount) = context {
  [(#amount points)] // Display points.
  let st = tally.get()
  st.update(c => c + amount)
}

Having state as the default state value sounds wild. I didn’t study the issue, but I bet no one will every need it in their life. Maybe for some super sophisticated package, but I still can’t think of a use case for this.

#context for item in all-states.get().pairs() {
  [#item.at(0): #item.at(1).final()]
  linebreak()
}

Thanks for the feedback. I should have spend some more time cleaning up my code, but I already spend so much time on it. As you might guess by now, I’m trying to learn Typst by posing and answering questions on this forum.

Noted. Thanks!

This is a leftover from debugging. The initial state makes points work outside of exercise. Come to think of it, I should probably reset tally to the initial state every time I leave exercise. Right now, points outside of an exercise keeps adding to the state of the last exercise.

Having looked at the for loop syntax for the first time, it should have been:

#context for (lbl, st) in all-states.get() {
  [#lbl: #st.final()]
  linebreak()
}
2 Likes

This is the final code after incorporating Andrew’s feedback:

#let exc = counter("exercise")
#let init = state("initial_state", 0)
#let tally = state("current-state", init)
#let all-states = state("all-states", (:))

#let points(amount) = context {
  [(#amount points)] // Display points.
  let st = tally.get()    // get the current tally
  st.update(c => c + amount) // add the amount to the current tally
}

#let exercise(title: "", body) = {
  exc.step()
  // now we're accessing the exercise counter
  context{
    // let's create a new state with a unique name
    let name = "Exercise " + exc.display() + ": " + title
    let st = state(name, 0)
    // and put the new state in tally and all-states
    tally.update(st)
    all-states.update(dict => dict + ((name): st))
    // OK, now let's start displaying the title of our exercise:
    [*#name*
      #h(1fr)
      *(#st.final() points)*]
    linebreak()
    // here is the main body of the exercise
    body
    // closing
    [The total amount of points for this question is: #st.final().]
    // reset the tally so erroneous points won't add to this exercise's state
    tally.update(init)
  }
}

#exercise[
+ add 2 and 2. #points(3)
+ add 3 and 3. #points(2)
]

#exercise[
+ add 6 and 7. #points(3)
+ add 5 and 9. #points(1)
]

#let summary() = context{
  let total = 0
  [* To summarize: * \
    All states: \
  ]
  for (ex, st) in all-states.get() {
    total += st.final()
    [#ex: #st.final()]
    linebreak()
  }
  [*Total points: #total*]
}

#summary()

2 Likes

Hey, I actually ran into a very similar problem a while ago — I wanted to assign points to main tasks and subtasks (like Task 1 → 5 points, A → 2 points, B → 3 points) and have everything add up automatically.

After some experimenting I ended up creating a small Typst package for exactly this. It also lets you configure whether the points should be shown next to the main task, the subtasks, or both. You can even attach solutions, expectation horizons, or additional materials (for science subjects, for example) that can later be collected in a separate chapter.

I just made the package public on GitHub a few days ago — feel free to take a look if you’re interested:

:point_right: Typst-Schule – aufgaben package

aufgaben - example.pdf (31.6 KB)

Here’s a small example assuming you’ve already included the package locally:

#import "aufgaben.typ": *
#set page(height: auto)

#set-options((
  punkte: "alle",          // "keine" | "aufgaben" | "teilaufgaben" | "alle"
  loesungen: "folgend",    // "keine" | "sofort" | "folgend" | "seiten"
  materialien: "keine",   // "keine" | "sofort" | "folgend" | "seiten"
  teilaufgabe-numbering: "a)",
  workspaces: true,
))

// ── Aufgabe 1: mehrere Teilaufgaben ────────────────────────────────────────────
#aufgabe[Photosynthesis][
  #teilaufgabe[
    Name the main reactants and products.  
    #erwartung(2)[CO₂ and H₂O → glucose and O₂.]  
    #loesung[
      Reactants: CO₂, H₂O. Products: C₆H₁₂O₆, O₂. Mention stoichiometry and physical states.
    ]
  ]

  #teilaufgabe[
    Explain the role of sunlight.  
    #erwartung(3)[Light excites chlorophyll; drives ATP/NADPH formation.]  
    #loesung[
      Photons excite chlorophyll; electron transport builds ATP/NADPH powering the Calvin cycle.
    ]
  ]

  #material([Placeholder for diagram or dataset], caption: [Leaf cross-section])
]

// ── Aufgabe 2: Einzelaufgabe ohne Teilaufgaben ─────────────────────────────────
#aufgabe[Ohm’s Law experiment][
  Plan a simple circuit and determine the resistance.  
  #erwartung(4)[Correct setup and measurement with proper units.]  
  #loesung[
    Build a simple series circuit with a known voltage and a measured current.  
    Compute $R = U/I$ and include a short discussion of measurement uncertainty.
  ]
  #material([Photo of setup], caption: [Lab setup])
]

#show-erwartungen(grouped: false)

#show-bewertung(true)

#show-materialien()

With the materials on seperate pages

It automatically calculates and displays the total points, and you can choose where and how to show them.

Would love to hear your thoughts if you give it a try!

2 Likes

Thank you so much everyone, especially @bdr for the fast help and reply! I just released my first exercise sheet with typst :smile:

@vmartel08 Thank you for the suggestions, I already checked multiple task setting packages, but they were all too opinionated (or lesser documented) to understand for non native typst users.

Why I prefer this type of point calculation is that it allows for flexible structures, e. g.:

  • Main task
    • Subtask (1 points)
    • Subtask 2 (1 points)

and also

  • Main task (5 points)

I think about composing a small package that does only the point counter functionality if you guys think it is worth it.

2 Likes

You are welcome. Dealing with states and context is not easy at the beginning. I’ve also learned a lot answering this thread.

You should really try the one from @Mathemensch if you haven’t. It is really well designed and simple (if you speak German :sweat_smile:). I really like it. I have made an aufgaben demo version in english-ish here. It does exactly what you have requested, and much more.

@Mathemensch thanks for the nice package, I hope you will translate it in English and publish on Typst Universe.

1 Like

I’m glad I was able to help. Like I said, I learned a lot.

Please wait. I’m working on a tally-package that will do just that. It will not only provide tally-like running counters, like the points in exercise, but it can also optionally tie in to the hierarchy of your typst document.

That means you will have a grand total per chapter, per section, per subsection, etc.

I’ve got the bare bones working but it will take quite some massaging to get it into a publishable state, and I do not have time to work on it before late next week.