diff --git a/Cargo.lock b/Cargo.lock index 7394da540..2e11dc187 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1580,6 +1580,7 @@ dependencies = [ "roxmltree", "rustybuzz", "serde_json", + "serde_yaml", "smallvec", "syntect", "ttf-parser 0.18.1", diff --git a/assets/files/bad.yaml b/assets/files/bad.yaml new file mode 100644 index 000000000..330ec7a91 --- /dev/null +++ b/assets/files/bad.yaml @@ -0,0 +1 @@ +this_will_break: [) \ No newline at end of file diff --git a/assets/files/scifi-authors.yaml b/assets/files/scifi-authors.yaml new file mode 100644 index 000000000..1cffc760d --- /dev/null +++ b/assets/files/scifi-authors.yaml @@ -0,0 +1,11 @@ +"Arthur C. Clarke": + - title: Against the Fall of Night + published: "1978" + - title: The songs of distant earth + published: "1986" + +"Isaac Asimov": + - title: Quasar, Quasar, Burning Bright + published: "1977" + - title: Far as Human Eye Could See + published: 1987 diff --git a/assets/files/yamltypes.yaml b/assets/files/yamltypes.yaml new file mode 100644 index 000000000..eb7597779 --- /dev/null +++ b/assets/files/yamltypes.yaml @@ -0,0 +1,8 @@ +null_key: [null, ~] +"string": text +integer: 5 +float: 1.12 +mapping: { '1': "one", '2': "two"} +seq: [1, 2, 3, 4] +bool: false +true: bool \ No newline at end of file diff --git a/library/Cargo.toml b/library/Cargo.toml index a7f1f4180..d1fe4267a 100644 --- a/library/Cargo.toml +++ b/library/Cargo.toml @@ -23,6 +23,7 @@ once_cell = "1" roxmltree = "0.14" rustybuzz = "0.5" serde_json = "1" +serde_yaml = "0.8" smallvec = "1.10" syntect = { version = "5", default-features = false, features = ["default-syntaxes", "regex-fancy"] } ttf-parser = "0.18.1" diff --git a/library/src/compute/data.rs b/library/src/compute/data.rs index 0c17972d0..c2dc632bb 100644 --- a/library/src/compute/data.rs +++ b/library/src/compute/data.rs @@ -207,6 +207,96 @@ fn format_json_error(error: serde_json::Error) -> String { format!("failed to parse json file: syntax error in line {}", error.line()) } +/// Read structured data from a YAML file. +/// +/// The file must contain a valid YAML object or array. YAML mappings will be +/// converted into Typst dictionaries, and YAML sequences will be converted into +/// Typst arrays. Strings and booleans will be converted into the Typst +/// equivalents, null-values (`null`, `~` or empty ``) will be converted into +/// `{none}`, and numbers will be converted to floats or integers depending on +/// whether they are whole numbers. +/// +/// Note that mapping keys that are not a string cause the entry to be +/// discarded. +/// +/// Custom YAML tags are ignored, though the loaded value will still be +/// present. +/// +/// The function returns a dictionary or value or an array, depending on +/// the YAML file. +/// +/// The YAML files in the example contain objects with authors as keys, +/// each with a sequence of their own submapping with the keys +/// "title" and "published" +/// +/// ## Example +/// ```example +/// #let bookshelf(contents) = { +/// for author, works in contents { +/// author +/// for work in works [ +/// - #work.title (#work.published) +/// ] +/// } +/// } +/// +/// #bookshelf(yaml("scifi-authors.yaml")) +/// ``` +/// +/// Display: YAML +/// Category: data-loading +/// Returns: array or value or dictionary +#[func] +pub fn yaml( + /// Path to a YAML file. + path: Spanned, +) -> Value { + let Spanned { v: path, span } = path; + let path = vm.locate(&path).at(span)?; + let data = vm.world().file(&path).at(span)?; + let value: serde_yaml::Value = + serde_yaml::from_slice(&data).map_err(format_yaml_error).at(span)?; + convert_yaml(value) +} + +/// Convert a YAML value to a Typst value. +fn convert_yaml(value: serde_yaml::Value) -> Value { + match value { + serde_yaml::Value::Null => Value::None, + serde_yaml::Value::Bool(v) => Value::Bool(v), + serde_yaml::Value::Number(v) => match v.as_i64() { + Some(int) => Value::Int(int), + None => Value::Float(v.as_f64().unwrap_or(f64::NAN)), + }, + serde_yaml::Value::String(v) => Value::Str(v.into()), + serde_yaml::Value::Sequence(v) => { + Value::Array(v.into_iter().map(convert_yaml).collect()) + } + serde_yaml::Value::Mapping(v) => Value::Dict( + v.into_iter() + .map(|(key, value)| (convert_yaml_key(key), convert_yaml(value))) + .filter_map(|(key, value)| key.map(|key|(key, value))) + .collect(), + ) + } +} + +/// Converts an arbitary YAML mapping key into a Typst Dict Key. +/// Currently it only does so for strings, everything else +/// returns None +fn convert_yaml_key(key: serde_yaml::Value) -> Option { + match key { + serde_yaml::Value::String(v) => Some(Str::from(v)), + _ => None, + } +} + +/// Format the user-facing YAML error message. +#[track_caller] +fn format_yaml_error(error: serde_yaml::Error) -> String { + format!("failed to parse yaml file: {}", error.to_string().trim()) +} + /// Read structured data from an XML file. /// /// The XML file is parsed into an array of dictionaries and strings. XML nodes diff --git a/library/src/lib.rs b/library/src/lib.rs index 178db8a21..cabafd8cc 100644 --- a/library/src/lib.rs +++ b/library/src/lib.rs @@ -124,6 +124,7 @@ fn global(math: Module, calc: Module) -> Module { global.define("read", compute::read); global.define("csv", compute::csv); global.define("json", compute::json); + global.define("yaml", compute::yaml); global.define("xml", compute::xml); // Calc. diff --git a/tests/typ/compute/data.typ b/tests/typ/compute/data.typ index 43746e188..cfd761df6 100644 --- a/tests/typ/compute/data.typ +++ b/tests/typ/compute/data.typ @@ -41,6 +41,24 @@ // Error: 7-18 failed to parse json file: syntax error in line 3 #json("/bad.json") +--- +// Test reading YAML data +#let data = yaml("/yamltypes.yaml") +#test(data.len(), 7) +#test(data.null_key, (none, none)) +#test(data.string, "text") +#test(data.integer, 5) +#test(data.float, 1.12) +#test(data.mapping, ("1": "one", "2": "two")) +#test(data.seq, (1,2,3,4)) +#test(data.bool, false) +#test(data.keys().contains("true"), false) +--- + +--- +// Error: 7-18 failed to parse yaml file: while parsing a flow sequence, expected ',' or ']' at line 2 column 1 +#yaml("/bad.yaml") + --- // Test reading XML data. #let data = xml("/data.xml")