Technical details
Examy is written entirely in Typst, heavily relying on elembic for managing settings and options. Understanding this document is not needed for using examy, but might be of interest.
No nested boxes
A straightforward implementation of examy would wrap each question/part/subpart in a box and apply an appropriate inset. Unfortunately, #pagebreak() cannot work inside a box and, worse still, boxes can only negotiate fractional units if they are peers of each other. So, the box approach would make it impossible to have a part and a subpart both share the remaining space on the page.
Instead, examy flattens all question/part/subpart contents and tags the start and end of each block with metadata. The flat stream is then parsed, indentation level is tracked, and peer boxes are produced, so that #pagebreak() and fr units can negotiate space.
Step 1: Questions are annotated with metadata
question, part, and subpart don’t draw anything themselves. Each one returns its body unchanged but between two invisible markers (metadata values) that record the points, number, label, rubric, etc. associated with the question/part/subpart.
Additionally, a test is inserted into the content stream. This test is later removed if the exam is well-formed, but it allows the query typst eval --in exam.typ 'query(<examy-warning>)' to report information about a malformed exam.
Step 2: Layout
#exam[] receives the question stream (with metadata tags already inserted). It then runs through the following steps.
- Tokenize
The question stream is scanned for examy’s
metadataand#pagebreak()s. The stream is split to give special handling to breaks as well assetandshowrules. Warning markers are also removed from the stream. Everything else is kept whole. From here on, a question/part/subpart inside a box (e.g., list item, box, table, etc.) is an error.- Parse
The tokens become a tree of questions/parts/subparts with their numbers, points, and options pre-computed. This allows for a global analysis of the exam (e.g., for use in
#points-table()) without appealing tocontextorquerys.- Plan
A question with no
#pagebreak()and nofranswer box is drawn using nested blocks. Any other question is split into peer blocks, each indented to appear as if it is nested.- Render
Each block is drawn with its number and points badge. Labelled questions/parts/subparts get a Typst label that can be used for
@namereferences, and the point information recorded for#points-table(),#num-points(), etc.
Settings and elembic
The global config object and question references are controlled by elembic, which is re-exported as e.
- Settings
The global
configis an elembic element that is never drawn; its fields are examy’s options.#show: e.set_(config, ..)works like a Typstsetrule, applying until the end of its block, and#show: exam.with(..)applies its options the same way. Options are read withe.get, which, like Typst’scontext, gives the values in effect where it is placed. This is why#show-solutions(get)takesget.The exam reads its options where it starts. A
configrule inside the exam applies to the questions in its scope, except fornumbering-full,strings, andshow-rubrics, which hold for the whole exam.- References
Each labelled question/part/subpart leaves an invisible elembic element as its anchor. Elembic lets an element decide how references to it are drawn, so a reference can show “1 (a)” or just “(a)” depending on where it is.
#show: e.prepare()sets this up. The exam applies it to its own content, so a document only needs it for references from outside the exam.
While solution and answer-box are elembic elements, #exam[..], #question[..], #part[..], and #subpart[..] are plain Typst functions. However, during the Render phase of examy, these items are typeset using elembic elements (and so can respond to elembic’s set rules).