How to create a questions bank?

Hi everyone,

I’m working on a Typst project aimed at creating a question bank that compiles university entrance exams from all regions in Spain. I want to build a modular, reusable, and flexible system to generate customized exam sheets or collect all the questions from a complete module.

Project structure

  • A central file with the preamble, where I define functions like question, section, criteria, answer, and the general document layout.
  • Separate files for each region, acting as databases with all the questions, subdivided into sections, answers, and criteria.
  • A main file where I want to gather specific questions based on filters like year, exam session, region, or module, and optionally toggle the display of answers and evaluation criteria.

What I have so far

  • The file structure mostly set up.
  • The functions question, part, criteria, answer working at a basic level.

What I need help with

  • How can I make each question automatically calculate its total score, by summing the value of the section it contains?
  • Is there a way in Typst to filter questions based on metadata, such as:
    • Year (e.g., "2025")
    • Session (e.g., "Ordinary")
    • Region (e.g., "Andalusia")
    • Module (e.g., "Biomolecules")
  • How can I conditionally show or hide criteria and answers, depending on a global setting (like a variable show-answers: true)?

I know this is quite an ambitious project, but I believe Typst has great potential for educational applications like this.

Here’s the current draft of my document (still very much a work in progress):

https://typst.app/project/rGHm6Xv7SePAKwETQDXqu6

Any advice, ideas, or suggestions on how to approach these problems would be greatly appreciated. Thanks in advance!

2 Likes

I unfortunately don’t have time right now for a detailed answer, but as a starting point I’d look at the packages here: “exam” search results on Universe. Maybe one does what you want or gives you inspiration.

I’m obviously biased, but my own package for this is Scrutinize 0.3.0: author exams with Typst (link goes to the forum announcement). It explicitly supports the broad features you need help with: calculating sums of points, associating questions with metadata for filtering, and supporting answers shown based on a setting.

What makes Scrutinize a bit opinionated is that each question is a heading. This supports subquestions naturally, but I know some people prefer lists and other non-heading structures for organizing their exams.

Hi.

Depends on your structure. You either pass data from section to question and sum the scores, or work with metadata, context, and query.

Yes. Same as above, but instead of section to question, there will be question to questions.

Passing global to local is harder, but still: either through context metadata or by returning function with partially passed arguments.

Definitely not in terms of implementation, especially if the simplest-to-implement structure is used.

Here are my questions about the code, there is a lot to discuss. Hopefully this will give some answers on how to improve the documentation.

  • What does this supposed to import?

    #import "ANDALUCÍA/andal_banco.typ": *
    
    #import "ASTURIAS/astur_banco.typ": *
    
  • Is this “institute”?

    inst: none,
    
  • Why uppercase is for text.lang?

    set text(lang: "ES")
    
  • Why fractions are used for vertical spacing instead of length?

    • Why align is wrapped in block?
    • What horizontal alignment is supposed to do?
    • Why text.hyphenate is explicitly added if it’s not enabled by default?
      block(width: 100%,
        align(horizon+center,text(hyphenate: false, weight: "bold", size: 40pt,
          upper(title))
        )
      )
    
  • Why not use centered line with line.length?

      line(start: (25%, 0%), end: (75%, 0%))
    
  • This looks like a hack, use block.spacing.

      v(0.1fr)
    
  • Why 2 specifically? pagebreak.to

      pagebreak()
      pagebreak()
    
  • Why is this here when no headings are used?

      outline()
    
    • Why scoring exists, if total score is supposed to be calculated automatically?
    • Why you need underscores?
    • Why you need 3–4 char long names?
    #let question(
      scoring: 1,
      tag_reg: none,
      tag_year: none,
      tag_ses: none,
      tag_mod: none,
      body,
    ) = {
    
    • Why use block.width?
    • What box(width: 12.6pt,text(weight: "bold", enum(numbering: "1."))) supposed to do?
    • Why first 2 parts are explicitly joined through +, but last one is joined implicitly?
      block(width: 100%, below: 1.6em,
        if scoring == 1 {
          box(width: 12.6pt,text(weight: "bold", enum(numbering: "1.")))+[* (#scoring punto)*]
          body
        } else {
          box(width: 12.6pt,text(weight: "bold", enum(numbering: "1.")))+[* (#scoring puntos)*]
          body
        }
      )
    
  • Do you really benefit from not writing “o”?

      shw: false,
    
    • Why use block’s stroke and inset instead of just rect?
    • Why are you centering a 101% width block? Do you want block.outset?
    • Why horizon is used?
    • Why use text.weight and not strong or **?
    • Why use math symbol over square?
        align(center + horizon, block(width: 101%, stroke: black, inset: (top: 4pt, left: 2.25pt, right: 2.25pt, bottom: 6pt),
          align(left+horizon, text(weight: "bold", [Solución: ]) + body + align(right,block(inset: (right:3.75pt),$qed$)))
          )
        )
    
  • Why year is not an int?

      tag_year: "2025",
    

Some other stuff:

  • Where are images supposed to be used?
  • Why you need a filter, if you filter questions into different files?
  • If section only have one criterion and answer, why make separate functions instead of additional section parameters?
  • Why add name to banco.typ, if it’s already inside a named directory?

Here is a solution, that can be further significantly simplified, if you would mostly work with functions & data, and not content:

exam_year.typ
#import "preamble.typ": *

#show: title-book.with(
  title: "Ejercicios de exámenes",
  course: "Todas las comunidades",
  subject: "Por años",
  inst: none,
  show-answers: true,
  show-criteria: true,
  // filter: (year: 2024)
)

#include "ANDALUCÍA/andal_banco.typ"

// #include "ASTURIAS/astur_banco.typ"
preamble.typ
#let title-book(
  title: none,
  course: none,
  subject: none,
  inst: none,
  show-answers: false,
  show-criteria: false,
  filter: (:),
  body,
) = {
  set text(lang: "es")
  v(1.5fr)
  // block(width: 100%,
  //   align(horizon+center,text(hyphenate: false, weight: "bold", size: 40pt,
  //     upper(title))
  //   )
  // )
  align(horizon + center, {
    text(40pt, strong(upper(title)))
    v(1.75fr)
    set block(spacing: 1.2em)
    text(20pt, strong(course + " - " + subject))
    line(length: 50%)
    v(0.1fr)
    text(12pt, inst)
  })
  v(1fr)
  pagebreak(to: "odd")
  // pagebreak()
  outline()

  [#metadata(show-answers)<show-answers>]
  [#metadata(show-criteria)<show-criteria>]
  assert(type(filter) == dictionary)
  [#metadata(filter)<filter>]

  body
}

#let question(
  // scoring: 1,
  region: none,
  year: none,
  session: none,
  module: none,
  description: "Question description",
  ..sections,
) = context {
  let metadata = (
    region: region,
    year: year,
    session: session,
    module: module,
  )
  let filter = query(<filter>).last().value
  for (key, value) in filter {
    assert(key in metadata)
    if value != metadata.at(key) { return }
  }
  show: block.with(width: 100%, below: 1.6em)
  set enum(numbering: "a)")
  let (scorings, sections) = array.zip(..sections)
  let scoring = scorings.sum()
  let points = if scoring == 1 [punto] else [puntos]
  // box(width: 12.6pt,text(weight: "bold", enum(numbering: "1.")))
  h(12.6pt)
  [*(#scoring #points)*]
  description
  sections.join()
}

#let section(scoring: 1, body) = {
  let points = if scoring == 1 [punto] else [puntos]
  let body = {
    show: block.with(width: 100%)
    box(width: 100%, inset: (left: 16pt))[(#scoring #points) #body]
  }
  (scoring, body)
}

#let criteria(body) = context if query(<show-criteria>).last().value { body }

#let answer(body) = context {
  if not query(<show-answers>).last().value { return }
  show: rect.with(
    width: 100%,
    outset: (x: 2.25pt),
    inset: (top: 4pt, bottom: 6pt, rest: 0pt),
  )
  [*Solución:* #body]
  align(right, pad(right: 3.75pt, square(size: 6pt, fill: black)))
}
ANDALUCÍA/andal_banco.typ
#import "../preamble.typ": *


#question(
  // scoring: 1,
  region: "Andalucía",
  year: 2025,
  session: "Ordinaria",
  module: "Biomoléculas",
  description: [
    Los glóbulos rojos mantienen un contenido salino interno del 0,9 %, lo que es crucial para su función. Su membrana plasmática, compuesta en un alto porcentaje por fosfolípidos regula el equilibrio osmótico con el plasma sanguíneo. Considerando estos aspectos, razone:
  ],
  section(scoring: 0.5)[
    ¿Qué ocurriría con estas células si se inyectara a un individuo una solución salina que hiciera que la concentración final de sales en sangre fuese del 2,2 %?

    #criteria[
      0.25 por cada respuesta correcta.
    ]

    #answer[
      Los glóbulos rojos del organismo se encontrarían en un medio hipertónico y saldría agua de ellos (plasmólisis o crenación) con riesgo de muerte celular
    ]
  ],

  section(scoring: 0.5)[
    ¿Y si la concentración final de sales en sangre fuese del 0,01%?

    #criteria[
      0.25 por cada respuesta correcta.
    ]

    #answer[
      Los glóbulos rojos se encontrarían en un medio hipotónico y entraría agua en su interior, aumentando su volumen (turgencia) con el riesgo de estallido (citólisis)
    ]
  ],

  section(scoring: 1)[
    Indique la composición de los fosfolípidos y explique por qué su estructura los hace idóneos para formar membranas biológicas.

    #criteria[
      0.25 por cada respuesta correcta.
    ]

    #answer[
      Composición: una molécula de glicerina (glicerol), dos ácidos grasos y un ácido fosfórico unido a un aminoalcohol (alcohol) (0,4 puntos). Por su carácter anfipático las cabezas polares se orientan hacia el agua y las colas apolares hacia el lado opuesto, alejadas del agua, formando bicapas lipídicas (0,6 puntos)
    ]
  ],
)


Here is the simplest implementation:

questions_bank.typ
#import "ANDALUCÍA/banco.typ" as andal
// #import "ASTURIAS/banco.typ" as astur

#let questions-bank = (
  ..andal.questions,
)
exam_year.typ
#import "preamble.typ": questions, title-book
#import "questions_bank.typ": questions-bank

#show: title-book.with(
  title: "Ejercicios de exámenes",
  course: "Todas las comunidades",
  subject: "Por años",
  institute: "Institute",
)

#questions(
  show-answers: true,
  show-criteria: true,
  filter: (year: 2025),
  ..questions-bank,
)
preamble.typ
#let title-book(
  title: "Title",
  course: "Course",
  subject: "Subject",
  institute: none,
  body,
) = {
  set text(lang: "es")
  align(horizon + center, {
    set block(spacing: 1.2em)
    block(text(40pt, strong(upper(title))))
    v(17em)
    block(text(20pt)[*#course -- #subject*])
    line(length: 50%)
    if institute != none [#block(text(12pt, institute))]
  })
  pagebreak(to: "odd")
  body
}

/// Create a question.
#let question(
  region: none,
  year: none,
  session: none,
  module: none,
  description: "Question description",
  sections: (),
) = {
  let metadata = (
    region: region,
    year: year,
    session: session,
    module: module,
  )
  for (k, v) in metadata { assert(v != none, message: k + " is not set") }
  (metadata: metadata, description: description, sections: sections)
}

/// Create a section.
#let section(
  scoring: 1,
  problem: "Problem description",
  criterion: "Criterion",
  answer: "Answer",
) = (
  scoring: scoring,
  problem: problem,
  criterion: criterion,
  answer: answer,
)

/// Display a criterion.
#let display-criterion(body) = block(body)

/// Display an answer.
#let display-answer(body) = {
  show: rect.with(
    width: 100%,
    outset: (x: 2.25pt),
    radius: 3pt,
    inset: (top: 4pt, bottom: 6pt, rest: 0pt),
  )
  set block(spacing: 0pt)
  block[*Solución:* #body]
  align(right, pad(right: 3.75pt, square(size: 6pt, fill: black)))
}

/// Display a section.
#let display-section(
  scoring: 1,
  problem: "Problem description",
  criterion: "Criterion",
  answer: "Answer",
  show-answer: false,
  show-criterion: false,
) = {
  let points = if scoring == 1 [punto] else [puntos]
  show: enum.item
  [(#scoring #points) #problem]
  if show-criterion { display-criterion(criterion) }
  if show-answer { display-answer(answer) }
}


/// Display filtered questions.
#let display-question(
  show-answers: false,
  show-criteria: false,
  filter: (:),
  question,
) = {
  let (sections, description) = question
  let max-score = sections.map(s => s.scoring).sum()
  let points = if max-score == 1 [punto] else [puntos]
  show: enum.item
  set block(below: 1.6em)
  set enum(numbering: "a)")
  [*(#max-score #points)* #description]
  for section in sections {
    display-section(
      show-answer: show-answers,
      show-criterion: show-criteria,
      ..section,
    )
  }
}

/// Display filtered questions.
#let questions(
  show-answers: false,
  show-criteria: false,
  filter: (:),
  ..questions,
) = {
  assert(type(filter) == dictionary)
  questions = questions
    .pos()
    .filter(q => filter.pairs().all(((k, v)) => q.metadata.at(k) == v))
  for question in questions {
    display-question(
      show-answers: show-answers,
      show-criteria: show-criteria,
      question,
    )
  }
}
ANDALUCÍA/banco.typ
#import "../preamble.typ": *
#let questions = (
  question(
    region: "Andalucía",
    year: 2025,
    session: "Ordinaria",
    module: "Biomoléculas",
    description: [
      Los glóbulos rojos mantienen un contenido salino interno del 0,9 %, lo que
      es crucial para su función. Su membrana plasmática, compuesta en un alto
      porcentaje por fosfolípidos regula el equilibrio osmótico con el plasma
      sanguíneo. Considerando estos aspectos, razone:
    ],
    sections: (
      section(
        scoring: 0.5,
        problem: "¿Qué ocurriría con estas células si se inyectara a un individuo una solución salina que hiciera que la concentración final de sales en sangre fuese del 2,2 %?",
        criterion: "0.25 por cada respuesta correcta.",
        answer: "Los glóbulos rojos del organismo se encontrarían en un medio hipertónico y saldría agua de ellos (plasmólisis o crenación) con riesgo de muerte celular",
      ),
      section(
        scoring: 0.5,
        problem: "¿Y si la concentración final de sales en sangre fuese del 0,01%?",
        criterion: "0.25 por cada respuesta correcta.",
        answer: "Los glóbulos rojos se encontrarían en un medio hipotónico y entraría agua en su interior, aumentando su volumen (turgencia) con el riesgo de estallido (citólisis)",
      ),
      section(
        scoring: 1,
        problem: "Indique la composición de los fosfolípidos y explique por qué su estructura los hace idóneos para formar membranas biológicas.",
        criterion: "0.25 por cada respuesta correcta.",
        answer: "Composición: una molécula de glicerina (glicerol), dos ácidos grasos y un ácido fosfórico unido a un aminoalcohol (alcohol) (0,4 puntos). Por su carácter anfipático las cabezas polares se orientan hacia el agua y las colas apolares hacia el lado opuesto, alejadas del agua, formando bicapas lipídicas (0,6 puntos)",
      ),
    ),
  ),
)

No context is used, and filtering is only done once, so it’s as performant as it gets. The rest is just styling of preprocessed data.

You can skip the structure functions and just use raw dictionaries, but it might introduce some other issues.