I’d like to keep the typst source code for exercises and solutions in the same place. The exercises should be rendered in place. But the solutions should be collected and rendered in a section at the end, with links back to the exercises.
I’ve seen several exam-like packages. But I don’t think I’ve seen this feature.
My use case is a book-like (or monograph) document. I currently just do it manually, adding labels, skipping back and forth.
If I knew scripting better, I suppose I could put the content into an array with labels, and process it at the end. But I just started with typst.
#let show-solutions = true
#let all-problems = state("all-problems", ())
#let problem(title: "Example title", statement: [], solution: []) = {
// Collect our title and solution into our all-problems state
context {
let current-problems = all-problems.get()
current-problems.push((title: title, solution: solution, location: here()))
all-problems.update(current-problems)
}
// Display the statement
[
== #title #label(title)
#statement
]
}
= Problem list
#problem(
title: "Basic arithmetic",
statement: [
1. 1+1 = ?
2. 2+1 = ?
],
solution: [
1. 1+1 = 2
2. 2+1 = 3
]
)
#problem(
title: "More stuff",
statement: [
1. something
],
solution: [
1. some solution
]
)
#if show-solutions [
#pagebreak()
= Solutions
#context {
let problems = all-problems.get()
for p in problems [
== #p.title #link(p.location, text(blue)[(go to problem)])
#p.solution
]
}
]
Breakdown
The show-solutions flag allows you to switch between rendering or not rendering the solutions.
We have the #problem wrapper for our problems. This enables writing the problems and solutions in the same place in your source file.
As variables defined outside our functions are read-only, we have to use state in order to keep track of our solutions and show them at the end. Notice that the get function for state is contextual, so we have to use context when using it.
For each problem we store its title, solution and location so we can link back to it.
I assumed that you want to write a problem and its solution in the same place. If not, you can ignore all the state stuff and the problem wrapper and just “statically” write the solutions in the if block.
You are using all-problems.get() and all-problems.update(current-problems) in the same context, which always(?) leads to the converge issue, provided this happens 5 times. And it does here, if there are 5+ problems:
#let show-solutions = true
#let all-problems = state("all-problems", ())
#let problem(title: "Example title", statement: [], solution: []) = context {
let current-problems = all-problems.get()
current-problems.push((title: title, solution: solution, location: here()))
all-problems.update(current-problems)
[
== #title #label(repr(title))
#statement
]
}
= Problem list
#for n in range(1, 6) {
problem(title: [Problem #n])
}
#if show-solutions [
#pagebreak()
= Solutions
#context {
for p in all-problems.get() [
== #p.title #link(p.location, text(blue)[(go to problem)])
#p.solution
]
}
]
To fix this, you should pass closure to the update function instead of overriding the value. Also the title can’t be content, because of the label, which is “fixed” with repr() content wrapper.
#let show-solutions = true
#let all-problems = state("all-problems", ())
#let problem(title: "Example title", statement: [], solution: []) = context {
let problem = (title: title, solution: solution, location: here())
all-problems.update(problems => problems + (problem,))
[
== #title #label(repr(title))
#statement
]
}
= Problem list
#for n in range(1, 6) {
problem(title: [Problem #n])
}
#if show-solutions [
#pagebreak()
= Solutions
#context {
for p in all-problems.get() [
== #p.title #link(p.location, text(blue)[(go to problem)])
#p.solution
]
}
]
This is a great start. I had looked at the abbr package as a model for collecting things in a dict and then pulling things out and rendering them. But what you have here is closer to a complete solution. I’ll mark the topic as solved once I play with it a bit. I guess I’ll end up putting it in a public package.
I don’t need to code much for my current role. I’ve been disciplined about not coding (it’s such a time sink). But there are so many things I could use to improve my notes that I know would be not too much work… “Stop me before I code again”.
I’d like to have the exercises numbered with the major part following the heading major part. For example, “Exercise 2.1”, “Exercise 2.2”, etc. for exercises in the second major heading.
The, the solution should refer to these numbers. Something like “Solution Exercise 2.1: solution text” with the number linking back to the statement in the text.
The following almost does this. But, each number in the solution is actually the number for the previous exercise. (And the first exercise number in the solutions is 0.0)
exercises.typ:
#let all-problems = state("all-problems", ())
#let c = counter("problem")
#let problem(statement: [], solution: none) = context {
let head_maj = counter(heading).get().at(0)
let prob_maj = c.get().at(0)
if prob_maj == 0 {
let head_min = counter(heading).get().at(1, default: 1)
c.update((head_maj, head_min))
} else if head_maj == prob_maj {
c.step(level: 2)
} else {
c.update((head_maj, 1))
}
[
_Exercise_ #context emph(c.display()): #statement #label(repr(c))\
]
let prob_maj = c.get().at(0)
let prob_min = c.get().at(1, default: 0)
let problem = (solution: solution, location: here(), number: (prob_maj, prob_min)) // c.get())
all-problems.update(problems => problems + (problem,))
}
#let display-solutions() = {
context {
for p in all-problems.get() {
if p.solution != none {
let maj = p.number.at(0, default: 1)
let min = p.number.at(1, default: 1)
[_Solution:_ #link(p.location, [_Exercise_ #emph[#maj\.#min]]) #h(1em) #p.solution\ \ ]
}
}
}
}
Then used in a document
#set heading(numbering: "1.1")
#import "/lib/exercises.typ": problem, display-solutions
= First Chapter
#problem(
statement: [What is $1 + 1$ ?],
solution: [The answer is $2$]
)
#problem(
statement: [What is $1 + 2$ ?],
solution: [The answer is $3$]
)
= Second Chapter
#problem(
statement: [Then what is $1 + 3$ ?],
solution: [The answer is $4$]
)
= The Solutions
#display-solutions()
I don’t follow on the numbering logic, but the issue is that you fetch updated counter state within the same context where it was updated. It won’t work, you need a new context.
Code
#let all-problems = state("all-problems", ())
#let problem-counter = counter("problem")
#let problem(statement: [], solution: none) = context {
let heading-numbers = counter(heading).get()
let head-maj = heading-numbers.first()
let prob-maj = problem-counter.get().first()
if prob-maj == 0 {
let head-min = heading-numbers.at(1, default: 1)
problem-counter.update((head-maj, head-min))
} else if head-maj == prob-maj {
problem-counter.step(level: 2)
} else {
problem-counter.update((head-maj, 1))
}
context {
let label = label(repr(problem-counter))
let problem-number = problem-counter.display()
par[_Exercise #problem-number:_ #statement#label]
let prob_maj = problem-counter.get().first()
let prob_min = problem-counter.get().at(1, default: 0)
let problem = (
solution: solution,
location: here(),
number: (prob_maj, prob_min),
)
all-problems.update(problems => problems + (problem,))
}
}
#let display-solutions() = context {
for p in all-problems.get() {
if p.solution != none {
let maj = p.number.at(0, default: 1)
let min = p.number.at(1, default: 1)
let exercise-link = link(p.location, emph[Exercise #maj.#min])
par[_Solution:_ #exercise-link #h(1em) #p.solution]
}
}
}
#set heading(numbering: "1.1")
= First Chapter
#problem(statement: [What is $1 + 1$ ?], solution: [The answer is $2$])
#problem(statement: [What is $1 + 2$ ?], solution: [The answer is $3$])
= Second Chapter
#problem(statement: [Then what is $1 + 3$ ?], solution: [The answer is $4$])
= The Solutions
#display-solutions()
You also get and override problem-counter in the same context, which is probably bad, but I don’t know how to get converge warning. Probably because the counter doesn’t update itself with its previous value, but rather with other counter’s value. I guess in its current form, it’s fine.
Great, this works, and neatens the code up a bit. That’s the first typst code I wrote (other than the simplest functions).
I replaced your par by block because “display” math equations cannot go inside paragraphs.
The numbering logic so far is just “Excersise x.y”, where x is the major part of the heading counter, and y restarts at 1 in each heading. In other words, I am following a standard that is common in text books.
I am not sure that this is the numbering I want in the end because my top level sections are really long; I may want to include the next heading level in the exercise. But, I wanted to start with something that is correct.
To make it fancier, I might allow a user to pass a function (if this can be done in typst) or else options, to customize how solutions are displayed.
The other thing would be to distinguish exercises by section, so that solutions could be organized by chapter. But those can be added later.
It’s not “the major part of the heading counter”, it’s the number of the top-/1st level heading. It’s not a versioning schema, it sounds confusing. Also y doesn’t restart after each heading, only after the top-level heading. Which is indeed a common per-section numbering, like in subpar – Typst Universe and i-figured – Typst Universe.
Here is a simple solution:
#let all-problems = state("all-problems", ())
#let problem-counter = counter("problem")
#let problem(statement: [], solution: none) = {
problem-counter.step()
context {
let h1-num = counter(heading).get().first()
let problem-number = numbering("1.1", h1-num, ..problem-counter.get())
let name = [Exercise #problem-number]
let problem = (solution: solution, location: here(), name: name)
all-problems.update(problems => problems + (problem,))
block[_#name:_ #statement]
}
}
#let display-solutions() = context for p in all-problems.get() {
if p.solution == none { continue }
let exercise-link = link(p.location, emph(p.name))
block[_Solution:_ #exercise-link#h(1em)#p.solution]
}
#set heading(numbering: "1.1")
#show heading.where(level: 1): it => it + problem-counter.update(0)
= First Chapter
#problem(statement: [What is $1 + 1$ ?], solution: [The answer is $2$])
#problem(statement: [What is $1 + 2$ ?], solution: [The answer is $3$])
== Sub
#problem(statement: [What is $1 + 3$ ?], solution: [The answer is $4$])
= Second Chapter
#problem(statement: [Then what is $1 + 4$ ?], solution: [The answer is $5$])
= The Solutions
#display-solutions()
I realize that these aren’t version numbers. But version numbers and the heading counter are sometimes represented in the same way, as a tuple of integers. And they both represent hierarchical organization. Of course, it’s better to use more appropriate language. I mean, I don’t want to start talking about a patch level headings.
I was not clear when I wrote “y restarts at 1 in each heading”. I meant that it restarts at one at each top-level heading only. Both your previous solution and your latest do this correctly (assuming this is what one wants— I did)
But, I like your latest code better. It looks cleaner and more flexible to me.
If you like how fast the compilation is, then don’t bother with memory. Typst tries to be as efficient internally as it can, even if sometimes it looks inefficient in the code. Though it’s not perfect.
The headings are only identified by their ordinal levels. The more appropriate/correct language you use, the easier it is to get the message across.