//! 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), /// 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) -> Value { Value::Str(s.into()) } /// A content-block value. pub fn content(s: impl Into) -> 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, 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, value: Option) { 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}"); } }