diff --git a/src/export/typst/templates/exam.typ b/src/export/typst/templates/exam.typ index fa803ca..1841f2c 100644 --- a/src/export/typst/templates/exam.typ +++ b/src/export/typst/templates/exam.typ @@ -61,7 +61,8 @@ #let show-points = extra.at("show-points", default: true) #let page-per-item = extra.at("page-per-item", default: false) #let show-bubbles = extra.at("show-bubbles", default: true) -#let bubble-radius = eval(extra.at("bubble-radius", default: "0.42em")) +#let bubble-radius = eval(extra.at("bubble-radius", default: "0.5em")) +#let scratch-space = eval(extra.at("scratch-space", default: "1.5em")) #let form-note = if cb-meta.form.at("count", default: 1) > 1 { " · Form " + cb-meta.form.id @@ -76,7 +77,7 @@ #cb-meta.course.code · #cb-meta.assessment.title#form-note ], footer: context text(size: 0.85em)[ - #counter(page).display("Page 1 of 1", both: true) + Page #counter(page).display("1 of 1", both: true) ], ) #set text(font: body-font, size: body-size, lang: "en") @@ -129,6 +130,31 @@ // ever generate both. #let bubble() = circle(radius: bubble-radius, stroke: 0.5pt) +// A small colored badge for a question's cognitive level, in the same visual +// style as the tier badges on Exam 4. `level` counts up from 1; swap the order +// of `level-colors` if your taxonomy numbers complexity the other way. Nothing +// is drawn when a variant withholds `level` (`fields.level: false`), same as +// any other optional field. +#let level-colors = ( + (bg: rgb("#D9E8EE"), fg: rgb("#264653")), + (bg: rgb("#DCF6F3"), fg: rgb("#2a9d8f")), + (bg: rgb("#FAF2DD"), fg: rgb("#D19F1F")), + (bg: rgb("#FBE6E0"), fg: rgb("#E24E29")), + (bg: rgb("#F0E9F5"), fg: rgb("#9967B6")), +) + +#let level-tag(q) = { + let level = q.at("level", default: none) + if level != none { + let idx = calc.max(1, calc.min(level, level-colors.len())) - 1 + let colors = level-colors.at(idx) + let label = q.at("level-name", default: "Level " + str(level)) + box(fill: colors.bg, radius: 3pt, inset: (x: 6pt, y: 3pt))[ + #text(weight: "bold", size: 0.75em, fill: colors.fg)[#label] + ] + } +} + // ───────────────────────────────────────────────────────────────────────────── // The renderer // ───────────────────────────────────────────────────────────────────────────── @@ -146,38 +172,44 @@ // The number is the one recorded in the assessment record, not the position on // the page. Keep it that way: it is the join key to every grading export. - // - // Built in code rather than written as `*#q.number.*` because a field access - // followed by a literal period reads as the start of another field access. let number-label = str(q.number) + "." - block(above: 1.2em, below: 0.5em)[ - *#number-label* #points-tag(q) #markup(q.stem) - ] - - if q.at("multi-select", default: false) { - block(below: 0.4em)[ - #text(size: 0.9em, style: "italic")[Select all that apply.] + block(breakable: false)[ + #block(above: 1.2em, below: 0.3em)[ + #grid( + columns: (1fr, auto), + align: (left + horizon, right + horizon), + [*#number-label* #points-tag(q)], level-tag(q), + ) ] - } + #block(below: 1.5em)[#markup(q.stem)] - block(inset: (left: 1.2em))[ - #for opt in q.at("options", default: ()) { - if show-bubbles { - grid( - columns: (1.6em, 1.4em, 1fr), - gutter: 0.2em, - align(horizon)[#bubble()], [#(opt.letter + ".")], [#markup(opt.text)], - ) - } else { - grid( - columns: (1.4em, 1fr), - gutter: 0.2em, - [#(opt.letter + ".")], [#markup(opt.text)], - ) - } - v(0.15em) + #if q.at("multi-select", default: false) { + block(below: 0.4em)[ + #text(size: 0.9em, style: "italic")[Select all that apply.] + ] } + + #block(inset: (left: 1.2em))[ + #for opt in q.at("options", default: ()) { + if show-bubbles { + grid( + columns: (1.6em, 1.4em, 1fr), + gutter: 0.2em, + align(top)[#v(-bubble-radius / 2.1) #bubble()], [#(opt.letter + ".")], [#markup(opt.text)], + ) + } else { + grid( + columns: (1.4em, 1fr), + gutter: 0.2em, + [#(opt.letter + ".")], [#markup(opt.text)], + ) + } + v(0.15em) + } + ] + + #if scratch-space > 0pt { v(scratch-space) } ] if page-per-item { pagebreak(weak: true) } @@ -187,43 +219,95 @@ // The page // ───────────────────────────────────────────────────────────────────────────── +#set page(paper: paper, margin: 2cm) +#set text(font: body-font, size: body-size, lang: "en") +#set par(justify: false, leading: 0.7em) + +// ── Cover page: no header or footer, so it reads as its own sheet. ── +#set page(header: none, footer: none) + +#v(4em) #align(center)[ - #text(size: 1.4em, weight: "bold", fill: accent)[#cb-meta.assessment.title]\ - #text(size: 0.95em)[ - #cb-meta.course.code — #cb-meta.course.title · #cb-meta.assessment.term + #text(weight: "bold", size: 18pt, fill: accent)[ + #cb-meta.course.code — #cb-meta.course.title ]\ - #text(size: 0.9em)[ + #text(size: 16pt)[#cb-meta.assessment.title#form-note]\ + #text(size: 14pt)[ #cb-meta.assessment.at("date", default: "") #{ let m = cb-meta.assessment.at("minutes-allowed", default: none) if m != none { " · " + str(int(calc.round(m))) + " minutes" } } - #{ - let p = cb-meta.totals.at("points", default: none) - if p != none { " · " + fmt-points(p) } - } - ] + ]\ + #{ + let p = cb-meta.totals.at("points", default: none) + if p != none { text(size: 12pt)[#fmt-points(p)] } + } ] -#if show-name-block { - block(above: 1em, below: 1.5em)[ - #grid( - columns: (auto, 1fr, auto, 1fr), - gutter: 0.6em, - [*Name*], box(width: 100%, repeat[.]), [*Student ID*], box(width: 100%, repeat[.]), - ) - ] -} +#v(1.5em) #{ let instructions = cb-meta.assessment.at("instructions", default: none) - if instructions != none { - block(fill: luma(245), inset: 8pt, radius: 3pt, width: 100%)[ - #markup(instructions) - ] - } + if instructions != none [ + Please read the following instructions carefully before beginning your assessment. + + #v(1em) + + #markup(instructions) + ] } +#if show-name-block [ + #v(1.5em) + + I agree to follow the above instructions. I affirm that all work on this + assessment will be my own and that I will not give or receive any + unauthorized assistance. To have your assessment graded, you must write your + name, sign, and provide your student ID below. + + #v(1em) + + #grid( + columns: (50%, 50%), + rows: 6em, + [ + #v(1em) + #line(length: 18em) + + *Name* + ], + [ + #v(1em) + #line(length: 18em) + + *Signature* + ], + ) + + #line(length: 8em) + + *Student ID* +] + +#pagebreak() +// Intentionally blank. Pull this sheet and the cover above as one unit if you +// need the cover gone — nothing on the back of either one is a question. +#pagebreak() + +// ── The exam itself: header and footer turn on here. ── +#set page( + header: text(size: 0.85em)[ + #cb-meta.course.code · #cb-meta.assessment.title#form-note + ], + footer: context text(size: 0.85em)[ + Page #counter(page).display("1 of 1", both: true) + ], +) +// Restart the printed page count here too, so the footer reads "Page 1 of N" +// for the exam itself rather than counting the cover and the blank page. +#counter(page).update(1) + // coursebank:begin questions #render-question(( number: 1,