feat: fix exporting to pdf

This commit is contained in:
2026-08-30 21:20:03 -04:00
parent d38d89549c
commit b375e6c94f
2 changed files with 54 additions and 7 deletions
+5 -1
View File
@@ -259,7 +259,11 @@ pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Res
/// The Quarto YAML front matter.
fn front_matter(course: &CourseFile, record: &AssessmentFile, variant: Variant) -> String {
let title = format!("{}: {}", record.assessment.title, variant.title_word());
let title = if matches!(variant, Variant::Solutions) {
format!("{}: {}", record.assessment.title, variant.title_word())
} else {
record.assessment.title.clone()
};
let subtitle = format!("{} · {}", course.course.code, course.course.title);
let mut out = String::from("---\n");
out.push_str(&format!("title: \"{}\"\n", yaml_quote(&title)));
+48 -5
View File
@@ -329,10 +329,7 @@ fn choice_fragment(course: &CourseFile, placement: &Placement, item: &Item, form
));
}
if let Some(explanation) = &solution.explanation {
out.push_str(&format!(
"<p class=\"sol-explain\">{}</p>\n",
inline_html(explanation)
));
out.push_str(&explain_html(explanation));
}
}
@@ -377,7 +374,8 @@ fn choice_fragment(course: &CourseFile, placement: &Placement, item: &Item, form
out
}
/// An open-response fragment: the model answer, the rubric, accepted variants.
/// An open-response fragment: the model answer, the explanation, the rubric, and
/// the accepted variants.
fn open_fragment(course: &CourseFile, item: &Item) -> Option<String> {
let solution = item.solution.as_ref().filter(|s| !s.is_empty())?;
let mut out = String::new();
@@ -389,6 +387,10 @@ fn open_fragment(course: &CourseFile, item: &Item) -> Option<String> {
));
}
if let Some(explanation) = &solution.explanation {
out.push_str(&explain_html(explanation));
}
if !solution.rubric.is_empty() {
let caption = match solution.rubric_points() {
Some(total) => {
@@ -593,6 +595,18 @@ fn block_html(src: &str) -> String {
.join("")
}
/// Renders a solution explanation as one `<p class="sol-explain">` per blank-line-
/// separated paragraph. A single-paragraph explanation emits exactly one such
/// paragraph, unchanged from before; a multi-paragraph one keeps its breaks, and
/// every paragraph carries the class `questions.css` already styles.
fn explain_html(src: &str) -> String {
src.split("\n\n")
.map(str::trim)
.filter(|p| !p.is_empty())
.map(|p| format!("<p class=\"sol-explain\">{}</p>\n", inline_html(p)))
.collect()
}
// --- the encrypted bundle -----
/// The encrypted solutions bundle, matching the `solutions.js` v1 format.
@@ -805,6 +819,10 @@ items:
learning_objectives: [lo-enthalpy]
solution:
model_answer: "From $H = U + PV$ at constant pressure, $q_p = \\Delta H$."
explanation: |
At constant pressure the pressure-volume work is folded into H, so the heat equals the change in H.
That is why a calorimeter run at constant pressure reads the enthalpy change directly.
rubric:
- { description: "States $H = U + PV$.", points: 1 }
- { description: "Reaches $q_p = \\Delta H$.", points: 1 }
@@ -914,6 +932,31 @@ items:
assert!(open.contains("<span class=\"badge\">Also accepted</span>"));
}
#[test]
fn an_open_fragment_shows_the_model_answer_and_the_explanation() {
// Both fields render, in that order, so an open-response solution reads as
// the answer followed by the reasoning, the same as a choice fragment. A
// multi-paragraph explanation keeps its breaks as separate paragraphs.
let cat = catalog("open-explain");
let rec = record();
let frags = solution_fragments(&cat, &rec, &form()).unwrap();
let open = &frags.iter().find(|(id, _)| id == "q-open").unwrap().1;
assert!(open.contains("<div class=\"sol-model\">"), "model answer shown:\n{open}");
assert_eq!(
open.matches("<p class=\"sol-explain\">").count(),
2,
"each explanation paragraph is its own styled <p>:\n{open}"
);
assert!(open.contains("folded into H"), "first paragraph present:\n{open}");
assert!(
open.contains("reads the enthalpy change directly"),
"second paragraph present:\n{open}"
);
let model_at = open.find("sol-model").unwrap();
let explain_at = open.find("sol-explain").unwrap();
assert!(model_at < explain_at, "model answer comes before the explanation");
}
#[test]
fn inline_html_keeps_math_verbatim_and_escapes_prose() {
// A subscript inside math survives; angle brackets outside math are escaped.