summaryrefslogtreecommitdiff
path: root/crates/typst-library/src/loading
diff options
context:
space:
mode:
Diffstat (limited to 'crates/typst-library/src/loading')
-rw-r--r--crates/typst-library/src/loading/cbor.rs62
-rw-r--r--crates/typst-library/src/loading/csv.rs191
-rw-r--r--crates/typst-library/src/loading/json.rs101
-rw-r--r--crates/typst-library/src/loading/mod.rs89
-rw-r--r--crates/typst-library/src/loading/read.rs59
-rw-r--r--crates/typst-library/src/loading/toml.rs92
-rw-r--r--crates/typst-library/src/loading/xml.rs123
-rw-r--r--crates/typst-library/src/loading/yaml.rs83
8 files changed, 800 insertions, 0 deletions
diff --git a/crates/typst-library/src/loading/cbor.rs b/crates/typst-library/src/loading/cbor.rs
new file mode 100644
index 00000000..977059c3
--- /dev/null
+++ b/crates/typst-library/src/loading/cbor.rs
@@ -0,0 +1,62 @@
+use ecow::{eco_format, EcoString};
+use typst_syntax::Spanned;
+
+use crate::diag::{At, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{func, scope, Bytes, Value};
+use crate::World;
+
+/// Reads structured data from a CBOR file.
+///
+/// The file must contain a valid CBOR serialization. Mappings will be
+/// converted into Typst dictionaries, and 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.
+///
+/// Be aware that integers larger than 2<sup>63</sup>-1 will be converted to
+/// floating point numbers, which may result in an approximative value.
+#[func(scope, title = "CBOR")]
+pub fn cbor(
+ /// The engine.
+ engine: &mut Engine,
+ /// Path to a CBOR file.
+ ///
+ /// For more details, see the [Paths section]($syntax/#paths).
+ path: Spanned<EcoString>,
+) -> SourceResult<Value> {
+ let Spanned { v: path, span } = path;
+ let id = span.resolve_path(&path).at(span)?;
+ let data = engine.world.file(id).at(span)?;
+ cbor::decode(Spanned::new(data, span))
+}
+
+#[scope]
+impl cbor {
+ /// Reads structured data from CBOR bytes.
+ #[func(title = "Decode CBOR")]
+ pub fn decode(
+ /// cbor data.
+ data: Spanned<Bytes>,
+ ) -> SourceResult<Value> {
+ let Spanned { v: data, span } = data;
+ ciborium::from_reader(data.as_slice())
+ .map_err(|err| eco_format!("failed to parse CBOR ({err})"))
+ .at(span)
+ }
+
+ /// Encode structured data into CBOR bytes.
+ #[func(title = "Encode CBOR")]
+ pub fn encode(
+ /// Value to be encoded.
+ value: Spanned<Value>,
+ ) -> SourceResult<Bytes> {
+ let Spanned { v: value, span } = value;
+ let mut res = Vec::new();
+ ciborium::into_writer(&value, &mut res)
+ .map(|_| res.into())
+ .map_err(|err| eco_format!("failed to encode value as CBOR ({err})"))
+ .at(span)
+ }
+}
diff --git a/crates/typst-library/src/loading/csv.rs b/crates/typst-library/src/loading/csv.rs
new file mode 100644
index 00000000..6822505d
--- /dev/null
+++ b/crates/typst-library/src/loading/csv.rs
@@ -0,0 +1,191 @@
+use ecow::{eco_format, EcoString};
+use typst_syntax::Spanned;
+
+use crate::diag::{bail, At, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{cast, func, scope, Array, Dict, IntoValue, Type, Value};
+use crate::loading::Readable;
+use crate::World;
+
+/// Reads structured data from a CSV file.
+///
+/// The CSV file will be read and parsed into a 2-dimensional array of strings:
+/// Each row in the CSV file will be represented as an array of strings, and all
+/// rows will be collected into a single array. Header rows will not be
+/// stripped.
+///
+/// # Example
+/// ```example
+/// #let results = csv("example.csv")
+///
+/// #table(
+/// columns: 2,
+/// [*Condition*], [*Result*],
+/// ..results.flatten(),
+/// )
+/// ```
+#[func(scope, title = "CSV")]
+pub fn csv(
+ /// The engine.
+ engine: &mut Engine,
+ /// Path to a CSV file.
+ ///
+ /// For more details, see the [Paths section]($syntax/#paths).
+ path: Spanned<EcoString>,
+ /// The delimiter that separates columns in the CSV file.
+ /// Must be a single ASCII character.
+ #[named]
+ #[default]
+ delimiter: Delimiter,
+ /// How to represent the file's rows.
+ ///
+ /// - If set to `array`, each row is represented as a plain array of
+ /// strings.
+ /// - If set to `dictionary`, each row is represented as a dictionary
+ /// mapping from header keys to strings. This option only makes sense when
+ /// a header row is present in the CSV file.
+ #[named]
+ #[default(RowType::Array)]
+ row_type: RowType,
+) -> SourceResult<Array> {
+ let Spanned { v: path, span } = path;
+ let id = span.resolve_path(&path).at(span)?;
+ let data = engine.world.file(id).at(span)?;
+ self::csv::decode(Spanned::new(Readable::Bytes(data), span), delimiter, row_type)
+}
+
+#[scope]
+impl csv {
+ /// Reads structured data from a CSV string/bytes.
+ #[func(title = "Decode CSV")]
+ pub fn decode(
+ /// CSV data.
+ data: Spanned<Readable>,
+ /// The delimiter that separates columns in the CSV file.
+ /// Must be a single ASCII character.
+ #[named]
+ #[default]
+ delimiter: Delimiter,
+ /// How to represent the file's rows.
+ ///
+ /// - If set to `array`, each row is represented as a plain array of
+ /// strings.
+ /// - If set to `dictionary`, each row is represented as a dictionary
+ /// mapping from header keys to strings. This option only makes sense
+ /// when a header row is present in the CSV file.
+ #[named]
+ #[default(RowType::Array)]
+ row_type: RowType,
+ ) -> SourceResult<Array> {
+ let Spanned { v: data, span } = data;
+ let has_headers = row_type == RowType::Dict;
+
+ let mut builder = ::csv::ReaderBuilder::new();
+ builder.has_headers(has_headers);
+ builder.delimiter(delimiter.0 as u8);
+
+ // Counting lines from 1 by default.
+ let mut line_offset: usize = 1;
+ let mut reader = builder.from_reader(data.as_slice());
+ let mut headers: Option<::csv::StringRecord> = None;
+
+ if has_headers {
+ // Counting lines from 2 because we have a header.
+ line_offset += 1;
+ headers = Some(
+ reader
+ .headers()
+ .map_err(|err| format_csv_error(err, 1))
+ .at(span)?
+ .clone(),
+ );
+ }
+
+ let mut array = Array::new();
+ for (line, result) in reader.records().enumerate() {
+ // Original solution was to use line from error, but that is
+ // incorrect with `has_headers` set to `false`. See issue:
+ // https://github.com/BurntSushi/rust-csv/issues/184
+ let line = line + line_offset;
+ let row = result.map_err(|err| format_csv_error(err, line)).at(span)?;
+ let item = if let Some(headers) = &headers {
+ let mut dict = Dict::new();
+ for (field, value) in headers.iter().zip(&row) {
+ dict.insert(field.into(), value.into_value());
+ }
+ dict.into_value()
+ } else {
+ let sub = row.into_iter().map(|field| field.into_value()).collect();
+ Value::Array(sub)
+ };
+ array.push(item);
+ }
+
+ Ok(array)
+ }
+}
+
+/// The delimiter to use when parsing CSV files.
+pub struct Delimiter(char);
+
+impl Default for Delimiter {
+ fn default() -> Self {
+ Self(',')
+ }
+}
+
+cast! {
+ Delimiter,
+ self => self.0.into_value(),
+ v: EcoString => {
+ let mut chars = v.chars();
+ let first = chars.next().ok_or("delimiter must not be empty")?;
+ if chars.next().is_some() {
+ bail!("delimiter must be a single character");
+ }
+
+ if !first.is_ascii() {
+ bail!("delimiter must be an ASCII character");
+ }
+
+ Self(first)
+ },
+}
+
+/// The type of parsed rows.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum RowType {
+ Array,
+ Dict,
+}
+
+cast! {
+ RowType,
+ self => match self {
+ Self::Array => Type::of::<Array>(),
+ Self::Dict => Type::of::<Dict>(),
+ }.into_value(),
+ ty: Type => {
+ if ty == Type::of::<Array>() {
+ Self::Array
+ } else if ty == Type::of::<Dict>() {
+ Self::Dict
+ } else {
+ bail!("expected `array` or `dictionary`");
+ }
+ },
+}
+
+/// Format the user-facing CSV error message.
+fn format_csv_error(err: ::csv::Error, line: usize) -> EcoString {
+ match err.kind() {
+ ::csv::ErrorKind::Utf8 { .. } => "file is not valid utf-8".into(),
+ ::csv::ErrorKind::UnequalLengths { expected_len, len, .. } => {
+ eco_format!(
+ "failed to parse CSV (found {len} instead of \
+ {expected_len} fields in line {line})"
+ )
+ }
+ _ => eco_format!("failed to parse CSV ({err})"),
+ }
+}
diff --git a/crates/typst-library/src/loading/json.rs b/crates/typst-library/src/loading/json.rs
new file mode 100644
index 00000000..597cf4cc
--- /dev/null
+++ b/crates/typst-library/src/loading/json.rs
@@ -0,0 +1,101 @@
+use ecow::{eco_format, EcoString};
+use typst_syntax::Spanned;
+
+use crate::diag::{At, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{func, scope, Str, Value};
+use crate::loading::Readable;
+use crate::World;
+
+/// Reads structured data from a JSON file.
+///
+/// The file must contain a valid JSON value, such as object or array. JSON
+/// objects will be converted into Typst dictionaries, and JSON arrays will be
+/// converted into Typst arrays. Strings and booleans will be converted into the
+/// Typst equivalents, `null` will be converted into `{none}`, and numbers will
+/// be converted to floats or integers depending on whether they are whole
+/// numbers.
+///
+/// Be aware that integers larger than 2<sup>63</sup>-1 will be converted to
+/// floating point numbers, which may result in an approximative value.
+///
+/// The function returns a dictionary, an array or, depending on the JSON file,
+/// another JSON data type.
+///
+/// The JSON files in the example contain objects with the keys `temperature`,
+/// `unit`, and `weather`.
+///
+/// # Example
+/// ```example
+/// #let forecast(day) = block[
+/// #box(square(
+/// width: 2cm,
+/// inset: 8pt,
+/// fill: if day.weather == "sunny" {
+/// yellow
+/// } else {
+/// aqua
+/// },
+/// align(
+/// bottom + right,
+/// strong(day.weather),
+/// ),
+/// ))
+/// #h(6pt)
+/// #set text(22pt, baseline: -8pt)
+/// #day.temperature °#day.unit
+/// ]
+///
+/// #forecast(json("monday.json"))
+/// #forecast(json("tuesday.json"))
+/// ```
+#[func(scope, title = "JSON")]
+pub fn json(
+ /// The engine.
+ engine: &mut Engine,
+ /// Path to a JSON file.
+ ///
+ /// For more details, see the [Paths section]($syntax/#paths).
+ path: Spanned<EcoString>,
+) -> SourceResult<Value> {
+ let Spanned { v: path, span } = path;
+ let id = span.resolve_path(&path).at(span)?;
+ let data = engine.world.file(id).at(span)?;
+ json::decode(Spanned::new(Readable::Bytes(data), span))
+}
+
+#[scope]
+impl json {
+ /// Reads structured data from a JSON string/bytes.
+ #[func(title = "Decode JSON")]
+ pub fn decode(
+ /// JSON data.
+ data: Spanned<Readable>,
+ ) -> SourceResult<Value> {
+ let Spanned { v: data, span } = data;
+ serde_json::from_slice(data.as_slice())
+ .map_err(|err| eco_format!("failed to parse JSON ({err})"))
+ .at(span)
+ }
+
+ /// Encodes structured data into a JSON string.
+ #[func(title = "Encode JSON")]
+ pub fn encode(
+ /// Value to be encoded.
+ value: Spanned<Value>,
+ /// Whether to pretty print the JSON with newlines and indentation.
+ #[named]
+ #[default(true)]
+ pretty: bool,
+ ) -> SourceResult<Str> {
+ let Spanned { v: value, span } = value;
+ if pretty {
+ serde_json::to_string_pretty(&value)
+ } else {
+ serde_json::to_string(&value)
+ }
+ .map(|v| v.into())
+ .map_err(|err| eco_format!("failed to encode value as JSON ({err})"))
+ .at(span)
+ }
+}
diff --git a/crates/typst-library/src/loading/mod.rs b/crates/typst-library/src/loading/mod.rs
new file mode 100644
index 00000000..ae74df86
--- /dev/null
+++ b/crates/typst-library/src/loading/mod.rs
@@ -0,0 +1,89 @@
+//! Data loading.
+
+#[path = "cbor.rs"]
+mod cbor_;
+#[path = "csv.rs"]
+mod csv_;
+#[path = "json.rs"]
+mod json_;
+#[path = "read.rs"]
+mod read_;
+#[path = "toml.rs"]
+mod toml_;
+#[path = "xml.rs"]
+mod xml_;
+#[path = "yaml.rs"]
+mod yaml_;
+
+pub use self::cbor_::*;
+pub use self::csv_::*;
+pub use self::json_::*;
+pub use self::read_::*;
+pub use self::toml_::*;
+pub use self::xml_::*;
+pub use self::yaml_::*;
+
+use crate::foundations::{cast, category, Bytes, Category, Scope, Str};
+
+/// Data loading from external files.
+///
+/// These functions help you with loading and embedding data, for example from
+/// the results of an experiment.
+#[category]
+pub static DATA_LOADING: Category;
+
+/// Hook up all `data-loading` definitions.
+pub(super) fn define(global: &mut Scope) {
+ global.category(DATA_LOADING);
+ global.define_func::<read>();
+ global.define_func::<csv>();
+ global.define_func::<json>();
+ global.define_func::<toml>();
+ global.define_func::<yaml>();
+ global.define_func::<cbor>();
+ global.define_func::<xml>();
+}
+
+/// A value that can be read from a file.
+#[derive(Debug, Clone, PartialEq, Hash)]
+pub enum Readable {
+ /// A decoded string.
+ Str(Str),
+ /// Raw bytes.
+ Bytes(Bytes),
+}
+
+impl Readable {
+ pub fn as_slice(&self) -> &[u8] {
+ match self {
+ Readable::Bytes(v) => v,
+ Readable::Str(v) => v.as_bytes(),
+ }
+ }
+
+ pub fn as_str(&self) -> Option<&str> {
+ match self {
+ Readable::Str(v) => Some(v.as_str()),
+ Readable::Bytes(v) => std::str::from_utf8(v).ok(),
+ }
+ }
+}
+
+cast! {
+ Readable,
+ self => match self {
+ Self::Str(v) => v.into_value(),
+ Self::Bytes(v) => v.into_value(),
+ },
+ v: Str => Self::Str(v),
+ v: Bytes => Self::Bytes(v),
+}
+
+impl From<Readable> for Bytes {
+ fn from(value: Readable) -> Self {
+ match value {
+ Readable::Bytes(v) => v,
+ Readable::Str(v) => v.as_bytes().into(),
+ }
+ }
+}
diff --git a/crates/typst-library/src/loading/read.rs b/crates/typst-library/src/loading/read.rs
new file mode 100644
index 00000000..23e6e27e
--- /dev/null
+++ b/crates/typst-library/src/loading/read.rs
@@ -0,0 +1,59 @@
+use ecow::EcoString;
+use typst_syntax::Spanned;
+
+use crate::diag::{At, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{func, Cast};
+use crate::loading::Readable;
+use crate::World;
+
+/// Reads plain text or data from a file.
+///
+/// By default, the file will be read as UTF-8 and returned as a [string]($str).
+///
+/// If you specify `{encoding: none}`, this returns raw [bytes] instead.
+///
+/// # Example
+/// ```example
+/// An example for a HTML file: \
+/// #let text = read("example.html")
+/// #raw(text, lang: "html")
+///
+/// Raw bytes:
+/// #read("tiger.jpg", encoding: none)
+/// ```
+#[func]
+pub fn read(
+ /// The engine.
+ engine: &mut Engine,
+ /// Path to a file.
+ ///
+ /// For more details, see the [Paths section]($syntax/#paths).
+ path: Spanned<EcoString>,
+ /// The encoding to read the file with.
+ ///
+ /// If set to `{none}`, this function returns raw bytes.
+ #[named]
+ #[default(Some(Encoding::Utf8))]
+ encoding: Option<Encoding>,
+) -> SourceResult<Readable> {
+ let Spanned { v: path, span } = path;
+ let id = span.resolve_path(&path).at(span)?;
+ let data = engine.world.file(id).at(span)?;
+ Ok(match encoding {
+ None => Readable::Bytes(data),
+ Some(Encoding::Utf8) => Readable::Str(
+ std::str::from_utf8(&data)
+ .map_err(|_| "file is not valid utf-8")
+ .at(span)?
+ .into(),
+ ),
+ })
+}
+
+/// An encoding of a file.
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)]
+pub enum Encoding {
+ /// The Unicode UTF-8 encoding.
+ Utf8,
+}
diff --git a/crates/typst-library/src/loading/toml.rs b/crates/typst-library/src/loading/toml.rs
new file mode 100644
index 00000000..5167703e
--- /dev/null
+++ b/crates/typst-library/src/loading/toml.rs
@@ -0,0 +1,92 @@
+use ecow::{eco_format, EcoString};
+use typst_syntax::{is_newline, Spanned};
+
+use crate::diag::{At, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{func, scope, Str, Value};
+use crate::loading::Readable;
+use crate::World;
+
+/// Reads structured data from a TOML file.
+///
+/// The file must contain a valid TOML table. TOML tables will be converted into
+/// Typst dictionaries, and TOML arrays will be converted into Typst arrays.
+/// Strings, booleans and datetimes will be converted into the Typst equivalents
+/// and numbers will be converted to floats or integers depending on whether
+/// they are whole numbers.
+///
+/// The TOML file in the example consists of a table with the keys `title`,
+/// `version`, and `authors`.
+///
+/// # Example
+/// ```example
+/// #let details = toml("details.toml")
+///
+/// Title: #details.title \
+/// Version: #details.version \
+/// Authors: #(details.authors
+/// .join(", ", last: " and "))
+/// ```
+#[func(scope, title = "TOML")]
+pub fn toml(
+ /// The engine.
+ engine: &mut Engine,
+ /// Path to a TOML file.
+ ///
+ /// For more details, see the [Paths section]($syntax/#paths).
+ path: Spanned<EcoString>,
+) -> SourceResult<Value> {
+ let Spanned { v: path, span } = path;
+ let id = span.resolve_path(&path).at(span)?;
+ let data = engine.world.file(id).at(span)?;
+ toml::decode(Spanned::new(Readable::Bytes(data), span))
+}
+
+#[scope]
+impl toml {
+ /// Reads structured data from a TOML string/bytes.
+ #[func(title = "Decode TOML")]
+ pub fn decode(
+ /// TOML data.
+ data: Spanned<Readable>,
+ ) -> SourceResult<Value> {
+ let Spanned { v: data, span } = data;
+ let raw = std::str::from_utf8(data.as_slice())
+ .map_err(|_| "file is not valid utf-8")
+ .at(span)?;
+ ::toml::from_str(raw)
+ .map_err(|err| format_toml_error(err, raw))
+ .at(span)
+ }
+
+ /// Encodes structured data into a TOML string.
+ #[func(title = "Encode TOML")]
+ pub fn encode(
+ /// Value to be encoded.
+ value: Spanned<Value>,
+ /// Whether to pretty-print the resulting TOML.
+ #[named]
+ #[default(true)]
+ pretty: bool,
+ ) -> SourceResult<Str> {
+ let Spanned { v: value, span } = value;
+ if pretty { ::toml::to_string_pretty(&value) } else { ::toml::to_string(&value) }
+ .map(|v| v.into())
+ .map_err(|err| eco_format!("failed to encode value as TOML ({err})"))
+ .at(span)
+ }
+}
+
+/// Format the user-facing TOML error message.
+fn format_toml_error(error: ::toml::de::Error, raw: &str) -> EcoString {
+ if let Some(head) = error.span().and_then(|range| raw.get(..range.start)) {
+ let line = head.lines().count();
+ let column = 1 + head.chars().rev().take_while(|&c| !is_newline(c)).count();
+ eco_format!(
+ "failed to parse TOML ({} at line {line} column {column})",
+ error.message(),
+ )
+ } else {
+ eco_format!("failed to parse TOML ({})", error.message())
+ }
+}
diff --git a/crates/typst-library/src/loading/xml.rs b/crates/typst-library/src/loading/xml.rs
new file mode 100644
index 00000000..3b1a9674
--- /dev/null
+++ b/crates/typst-library/src/loading/xml.rs
@@ -0,0 +1,123 @@
+use ecow::EcoString;
+use roxmltree::ParsingOptions;
+use typst_syntax::Spanned;
+
+use crate::diag::{format_xml_like_error, At, FileError, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{dict, func, scope, Array, Dict, IntoValue, Str, Value};
+use crate::loading::Readable;
+use crate::World;
+
+/// Reads structured data from an XML file.
+///
+/// The XML file is parsed into an array of dictionaries and strings. XML nodes
+/// can be elements or strings. Elements are represented as dictionaries with
+/// the following keys:
+///
+/// - `tag`: The name of the element as a string.
+/// - `attrs`: A dictionary of the element's attributes as strings.
+/// - `children`: An array of the element's child nodes.
+///
+/// The XML file in the example contains a root `news` tag with multiple
+/// `article` tags. Each article has a `title`, `author`, and `content` tag. The
+/// `content` tag contains one or more paragraphs, which are represented as `p`
+/// tags.
+///
+/// # Example
+/// ```example
+/// #let find-child(elem, tag) = {
+/// elem.children
+/// .find(e => "tag" in e and e.tag == tag)
+/// }
+///
+/// #let article(elem) = {
+/// let title = find-child(elem, "title")
+/// let author = find-child(elem, "author")
+/// let pars = find-child(elem, "content")
+///
+/// heading(title.children.first())
+/// text(10pt, weight: "medium")[
+/// Published by
+/// #author.children.first()
+/// ]
+///
+/// for p in pars.children {
+/// if (type(p) == "dictionary") {
+/// parbreak()
+/// p.children.first()
+/// }
+/// }
+/// }
+///
+/// #let data = xml("example.xml")
+/// #for elem in data.first().children {
+/// if (type(elem) == "dictionary") {
+/// article(elem)
+/// }
+/// }
+/// ```
+#[func(scope, title = "XML")]
+pub fn xml(
+ /// The engine.
+ engine: &mut Engine,
+ /// Path to an XML file.
+ ///
+ /// For more details, see the [Paths section]($syntax/#paths).
+ path: Spanned<EcoString>,
+) -> SourceResult<Value> {
+ let Spanned { v: path, span } = path;
+ let id = span.resolve_path(&path).at(span)?;
+ let data = engine.world.file(id).at(span)?;
+ xml::decode(Spanned::new(Readable::Bytes(data), span))
+}
+
+#[scope]
+impl xml {
+ /// Reads structured data from an XML string/bytes.
+ #[func(title = "Decode XML")]
+ pub fn decode(
+ /// XML data.
+ data: Spanned<Readable>,
+ ) -> SourceResult<Value> {
+ let Spanned { v: data, span } = data;
+ let text = std::str::from_utf8(data.as_slice())
+ .map_err(FileError::from)
+ .at(span)?;
+ let document = roxmltree::Document::parse_with_options(
+ text,
+ ParsingOptions { allow_dtd: true, ..Default::default() },
+ )
+ .map_err(format_xml_error)
+ .at(span)?;
+ Ok(convert_xml(document.root()))
+ }
+}
+
+/// Convert an XML node to a Typst value.
+fn convert_xml(node: roxmltree::Node) -> Value {
+ if node.is_text() {
+ return node.text().unwrap_or_default().into_value();
+ }
+
+ let children: Array = node.children().map(convert_xml).collect();
+ if node.is_root() {
+ return Value::Array(children);
+ }
+
+ let tag: Str = node.tag_name().name().into();
+ let attrs: Dict = node
+ .attributes()
+ .map(|attr| (attr.name().into(), attr.value().into_value()))
+ .collect();
+
+ Value::Dict(dict! {
+ "tag" => tag,
+ "attrs" => attrs,
+ "children" => children,
+ })
+}
+
+/// Format the user-facing XML error message.
+fn format_xml_error(error: roxmltree::Error) -> EcoString {
+ format_xml_like_error("XML", error)
+}
diff --git a/crates/typst-library/src/loading/yaml.rs b/crates/typst-library/src/loading/yaml.rs
new file mode 100644
index 00000000..0e8ca3fb
--- /dev/null
+++ b/crates/typst-library/src/loading/yaml.rs
@@ -0,0 +1,83 @@
+use ecow::{eco_format, EcoString};
+use typst_syntax::Spanned;
+
+use crate::diag::{At, SourceResult};
+use crate::engine::Engine;
+use crate::foundations::{func, scope, Str, Value};
+use crate::loading::Readable;
+use crate::World;
+
+/// Reads 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. Custom YAML tags are ignored, though the
+/// loaded value will still be present.
+///
+/// Be aware that integers larger than 2<sup>63</sup>-1 will be converted to
+/// floating point numbers, which may give an approximative value.
+///
+/// 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")
+/// )
+/// ```
+#[func(scope, title = "YAML")]
+pub fn yaml(
+ /// The engine.
+ engine: &mut Engine,
+ /// Path to a YAML file.
+ ///
+ /// For more details, see the [Paths section]($syntax/#paths).
+ path: Spanned<EcoString>,
+) -> SourceResult<Value> {
+ let Spanned { v: path, span } = path;
+ let id = span.resolve_path(&path).at(span)?;
+ let data = engine.world.file(id).at(span)?;
+ yaml::decode(Spanned::new(Readable::Bytes(data), span))
+}
+
+#[scope]
+impl yaml {
+ /// Reads structured data from a YAML string/bytes.
+ #[func(title = "Decode YAML")]
+ pub fn decode(
+ /// YAML data.
+ data: Spanned<Readable>,
+ ) -> SourceResult<Value> {
+ let Spanned { v: data, span } = data;
+ serde_yaml::from_slice(data.as_slice())
+ .map_err(|err| eco_format!("failed to parse YAML ({err})"))
+ .at(span)
+ }
+
+ /// Encode structured data into a YAML string.
+ #[func(title = "Encode YAML")]
+ pub fn encode(
+ /// Value to be encoded.
+ value: Spanned<Value>,
+ ) -> SourceResult<Str> {
+ let Spanned { v: value, span } = value;
+ serde_yaml::to_string(&value)
+ .map(|v| v.into())
+ .map_err(|err| eco_format!("failed to encode value as YAML ({err})"))
+ .at(span)
+ }
+}