Files
coursebank/src/export/typst/value.rs
T
2026-08-06 16:45:50 -04:00

350 lines
11 KiB
Rust

//! A small writer for Typst literals.
//!
//! Everything this crate injects into a template is data, not layout, and data
//! has to arrive as syntax the Typst parser accepts. That is a narrow enough job
//! to do by hand: eight value kinds, one recursive printer, and no dependency on
//! a Typst implementation.
//!
//! Two details in here exist only because Typst's parenthesis syntax is
//! overloaded, and both are the kind of thing that produces a confusing compile
//! error rather than a clear one if you get them wrong:
//!
//! * An empty dictionary is `(:)`, not `()`, because `()` is the empty array.
//! * A one-element array needs a trailing comma — `(1,)` — because `(1)` is just
//! a parenthesized expression. Trailing commas are harmless everywhere else, so
//! this printer always emits them.
//!
//! [`Value::Content`] is the reason this is not simply a JSON writer. Stems and
//! option text are authored in a Typst subset, so they can be emitted as a
//! content block that Typst parses directly. The alternative is a quoted string
//! the template has to `eval`, which is what [`Value::Str`] gives you and what
//! JSON interoperability requires. Both are supported because both are useful;
//! see [`crate::typst::config::ContentMode`].
use std::fmt::Write as _;
/// One value in an emitted Typst literal.
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
/// Typst's `none`.
None,
/// A boolean.
Bool(bool),
/// An integer.
Int(i64),
/// A float. Non-finite values are written as `none`, since Typst has no
/// literal for them and a NaN in a point total is a bug worth seeing.
Float(f64),
/// A quoted string.
Str(String),
/// Markup, emitted as a content block: `[...]`.
Content(String),
/// Verbatim Typst code, emitted with no quoting or escaping at all.
Raw(String),
/// An array.
Array(Vec<Value>),
/// A dictionary. Insertion order is preserved, because the output is meant to
/// be read by a human comparing two exports.
Dict(Vec<(String, Value)>),
}
impl Value {
/// A string value.
pub fn str(s: impl Into<String>) -> Value {
Value::Str(s.into())
}
/// A content-block value.
pub fn content(s: impl Into<String>) -> Value {
Value::Content(s.into())
}
/// An empty dictionary, ready for [`Value::insert`].
pub fn dict() -> Value {
Value::Dict(Vec::new())
}
/// Adds a key to a dictionary, ignoring the call on any other kind.
///
/// # Arguments
///
/// * `key` - the dictionary key.
/// * `value` - the value to store.
pub fn insert(&mut self, key: impl Into<String>, value: Value) {
if let Value::Dict(entries) = self {
entries.push((key.into(), value));
}
}
/// Adds a key only when the value is `Some`, so an absent field is absent
/// from the output rather than present and `none`.
///
/// This distinction carries real weight for the answer key: an exam paper
/// whose payload omits `correct` cannot leak the key through a template that
/// forgot to check a flag, whereas one that emits `correct: none` invites a
/// template to treat the field as present.
///
/// # Arguments
///
/// * `key` - the dictionary key.
/// * `value` - the value, if there is one.
pub fn insert_some(&mut self, key: impl Into<String>, value: Option<Value>) {
if let Some(value) = value {
self.insert(key, value);
}
}
/// Whether this is a dictionary with no entries.
pub fn is_empty_dict(&self) -> bool {
matches!(self, Value::Dict(entries) if entries.is_empty())
}
/// Renders the value as a Typst literal.
///
/// # Arguments
///
/// * `indent` - how many levels of two-space indentation the value starts at.
/// Nested values indent relative to this.
///
/// # Returns
///
/// Typst source. Multi-line for non-empty arrays and dictionaries, single-line
/// for everything else.
pub fn to_typst(&self, indent: usize) -> String {
let mut out = String::new();
write_value(&mut out, self, indent);
out
}
}
/// Writes one value at the given indentation level.
fn write_value(out: &mut String, value: &Value, indent: usize) {
match value {
Value::None => out.push_str("none"),
Value::Bool(true) => out.push_str("true"),
Value::Bool(false) => out.push_str("false"),
Value::Int(n) => {
let _ = write!(out, "{n}");
}
Value::Float(x) => {
if x.is_finite() {
let _ = write!(out, "{x}");
} else {
out.push_str("none");
}
}
Value::Str(s) => write_string(out, s),
Value::Content(s) => {
out.push('[');
out.push_str(s);
out.push(']');
}
Value::Raw(s) => out.push_str(s),
Value::Array(items) => {
if items.is_empty() {
out.push_str("()");
return;
}
out.push_str("(\n");
for item in items {
pad(out, indent + 1);
write_value(out, item, indent + 1);
out.push_str(",\n");
}
pad(out, indent);
out.push(')');
}
Value::Dict(entries) => {
if entries.is_empty() {
// Not `()`, which is the empty array.
out.push_str("(:)");
return;
}
out.push_str("(\n");
for (key, item) in entries {
pad(out, indent + 1);
write_key(out, key);
out.push_str(": ");
write_value(out, item, indent + 1);
out.push_str(",\n");
}
pad(out, indent);
out.push(')');
}
}
}
/// Writes `n` levels of two-space indentation.
fn pad(out: &mut String, n: usize) {
for _ in 0..n {
out.push_str(" ");
}
}
/// Writes a dictionary key, quoting it when it is not a bare identifier.
fn write_key(out: &mut String, key: &str) {
if is_identifier(key) {
out.push_str(key);
} else {
write_string(out, key);
}
}
/// Whether a string can be used as a bare Typst identifier.
///
/// Typst identifiers allow interior hyphens, which is why `render-question` is a
/// legal function name and why this is not simply a Rust identifier check.
fn is_identifier(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
}
/// Writes a quoted, escaped Typst string.
fn write_string(out: &mut String, s: &str) {
out.push('"');
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(ch),
}
}
out.push('"');
}
/// Converts a parsed YAML value into a Typst value.
///
/// This is how arbitrary user configuration reaches a template: whatever is
/// under `extra` in the render config is carried through unexamined, so a
/// template can be given values this crate has never heard of.
///
/// # Arguments
///
/// * `value` - the YAML value.
/// * `strings_as_content` - when true, strings become content blocks rather than
/// quoted strings.
///
/// # Returns
///
/// The equivalent Typst value. YAML constructs with no Typst equivalent, such as
/// a tagged node, become `none`.
pub fn from_yaml(value: &serde_yaml_ng::Value, strings_as_content: bool) -> Value {
match value {
serde_yaml_ng::Value::Null => Value::None,
serde_yaml_ng::Value::Bool(b) => Value::Bool(*b),
serde_yaml_ng::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Value::Int(i)
} else if let Some(f) = n.as_f64() {
Value::Float(f)
} else {
Value::None
}
}
serde_yaml_ng::Value::String(s) => {
if strings_as_content {
Value::Content(s.clone())
} else {
Value::Str(s.clone())
}
}
serde_yaml_ng::Value::Sequence(items) => Value::Array(
items
.iter()
.map(|i| from_yaml(i, strings_as_content))
.collect(),
),
serde_yaml_ng::Value::Mapping(map) => {
let mut entries = Vec::new();
for (key, item) in map {
// A non-scalar key has no Typst spelling; skip it rather than
// emit something that will not parse.
let key = match key {
serde_yaml_ng::Value::String(s) => s.clone(),
serde_yaml_ng::Value::Number(n) => n.to_string(),
serde_yaml_ng::Value::Bool(b) => b.to_string(),
_ => continue,
};
entries.push((key, from_yaml(item, strings_as_content)));
}
Value::Dict(entries)
}
// A tagged node, and anything a future YAML version adds.
_ => Value::None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_containers_use_the_right_syntax() {
// `()` is the empty array and `(:)` is the empty dictionary. Swapping
// them produces a type error deep inside the template.
assert_eq!(Value::Array(Vec::new()).to_typst(0), "()");
assert_eq!(Value::dict().to_typst(0), "(:)");
}
#[test]
fn single_element_arrays_keep_the_trailing_comma() {
let v = Value::Array(vec![Value::Int(1)]);
let out = v.to_typst(0);
assert!(out.contains("1,"), "got {out}");
}
#[test]
fn strings_are_escaped() {
assert_eq!(Value::str("a\"b\\c").to_typst(0), "\"a\\\"b\\\\c\"");
assert_eq!(Value::str("two\nlines").to_typst(0), "\"two\\nlines\"");
}
#[test]
fn content_is_not_escaped() {
// Content blocks carry authored markup through verbatim; escaping them
// would turn `*bold*` into literal asterisks.
assert_eq!(Value::content("*bold*").to_typst(0), "[*bold*]");
}
#[test]
fn keys_are_quoted_only_when_they_have_to_be() {
let mut d = Value::dict();
d.insert("error-type", Value::Int(1));
d.insert("2nd", Value::Int(2));
let out = d.to_typst(0);
assert!(out.contains("error-type: 1"), "got {out}");
assert!(out.contains("\"2nd\": 2"), "got {out}");
}
#[test]
fn absent_fields_are_omitted_entirely() {
let mut d = Value::dict();
d.insert_some("correct", None);
d.insert_some("number", Some(Value::Int(3)));
assert!(!d.to_typst(0).contains("correct"));
}
#[test]
fn non_finite_floats_do_not_produce_invalid_syntax() {
assert_eq!(Value::Float(f64::NAN).to_typst(0), "none");
assert_eq!(Value::Float(1.5).to_typst(0), "1.5");
}
#[test]
fn yaml_passes_through() {
let yaml: serde_yaml_ng::Value =
serde_yaml_ng::from_str("a: 1\nb: [x, y]\nc: true\n").unwrap();
let out = from_yaml(&yaml, false).to_typst(0);
assert!(out.contains("a: 1"), "got {out}");
assert!(out.contains("\"x\""), "got {out}");
assert!(out.contains("c: true"), "got {out}");
}
}