feat: fix exporting to pdf
This commit is contained in:
@@ -259,7 +259,11 @@ pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Res
|
|||||||
|
|
||||||
/// The Quarto YAML front matter.
|
/// The Quarto YAML front matter.
|
||||||
fn front_matter(course: &CourseFile, record: &AssessmentFile, variant: Variant) -> String {
|
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 subtitle = format!("{} · {}", course.course.code, course.course.title);
|
||||||
let mut out = String::from("---\n");
|
let mut out = String::from("---\n");
|
||||||
out.push_str(&format!("title: \"{}\"\n", yaml_quote(&title)));
|
out.push_str(&format!("title: \"{}\"\n", yaml_quote(&title)));
|
||||||
|
|||||||
+49
-6
@@ -329,10 +329,7 @@ fn choice_fragment(course: &CourseFile, placement: &Placement, item: &Item, form
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Some(explanation) = &solution.explanation {
|
if let Some(explanation) = &solution.explanation {
|
||||||
out.push_str(&format!(
|
out.push_str(&explain_html(explanation));
|
||||||
"<p class=\"sol-explain\">{}</p>\n",
|
|
||||||
inline_html(explanation)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +374,8 @@ fn choice_fragment(course: &CourseFile, placement: &Placement, item: &Item, form
|
|||||||
out
|
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> {
|
fn open_fragment(course: &CourseFile, item: &Item) -> Option<String> {
|
||||||
let solution = item.solution.as_ref().filter(|s| !s.is_empty())?;
|
let solution = item.solution.as_ref().filter(|s| !s.is_empty())?;
|
||||||
let mut out = String::new();
|
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() {
|
if !solution.rubric.is_empty() {
|
||||||
let caption = match solution.rubric_points() {
|
let caption = match solution.rubric_points() {
|
||||||
Some(total) => {
|
Some(total) => {
|
||||||
@@ -593,6 +595,18 @@ fn block_html(src: &str) -> String {
|
|||||||
.join("")
|
.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 bundle -----
|
||||||
|
|
||||||
/// The encrypted solutions bundle, matching the `solutions.js` v1 format.
|
/// The encrypted solutions bundle, matching the `solutions.js` v1 format.
|
||||||
@@ -805,6 +819,10 @@ items:
|
|||||||
learning_objectives: [lo-enthalpy]
|
learning_objectives: [lo-enthalpy]
|
||||||
solution:
|
solution:
|
||||||
model_answer: "From $H = U + PV$ at constant pressure, $q_p = \\Delta H$."
|
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:
|
rubric:
|
||||||
- { description: "States $H = U + PV$.", points: 1 }
|
- { description: "States $H = U + PV$.", points: 1 }
|
||||||
- { description: "Reaches $q_p = \\Delta H$.", points: 1 }
|
- { description: "Reaches $q_p = \\Delta H$.", points: 1 }
|
||||||
@@ -914,6 +932,31 @@ items:
|
|||||||
assert!(open.contains("<span class=\"badge\">Also accepted</span>"));
|
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]
|
#[test]
|
||||||
fn inline_html_keeps_math_verbatim_and_escapes_prose() {
|
fn inline_html_keeps_math_verbatim_and_escapes_prose() {
|
||||||
// A subscript inside math survives; angle brackets outside math are escaped.
|
// A subscript inside math survives; angle brackets outside math are escaped.
|
||||||
@@ -973,4 +1016,4 @@ items:
|
|||||||
assert!(assets[0].1.contains(".qsol"));
|
assert!(assets[0].1.contains(".qsol"));
|
||||||
assert!(assets[1].1.contains("AES-GCM"));
|
assert!(assets[1].1.contains("AES-GCM"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user