feat: improve typst handling
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
//! 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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user