use std::collections::BTreeMap;
use std::path::Path;
use carta_ast::{
Document, Inline, MetaValue, single_block_inlines, to_plain_inlines, to_plain_text,
};
use carta_core::sections::build_toc;
use carta_core::template::{Template, Value};
use carta_core::{MathMethod, MetaVarStyle, Result, TocStyle, Writer, WriterOptions};
pub(crate) fn merge_metadata(document: &mut Document, options: &WriterOptions) {
if options.metadata_defaults.is_empty() && options.metadata.is_empty() {
return;
}
let mut merged = options.metadata_defaults.clone();
for (key, value) in std::mem::take(&mut document.meta) {
merged.insert(key, value);
}
for (key, value) in &options.metadata {
merged.insert(key.clone(), value.clone());
}
document.meta = merged;
}
pub(crate) fn render(
writer: &dyn Writer,
document: &Document,
body: &str,
options: &WriterOptions,
to_base: &str,
) -> Result<Option<String>> {
if let Some(structural) = writer.standalone_document(document, options)? {
return Ok(Some(structural));
}
let source = match &options.template {
Some(text) => text.clone(),
None => match writer.default_template() {
Some(text) => text.to_owned(),
None => return Ok(None),
},
};
let template = Template::parse(&source)?;
let context = build_context(document, writer, body, options)?;
let dir = options.template_dir.clone();
let ext = options
.template_ext
.clone()
.unwrap_or_else(|| to_base.to_owned());
let resolve = move |name: &str| resolve_partial(dir.as_deref(), &ext, name);
let mut output = template.render(&context, &resolve)?;
let kept = output.trim_end_matches('\n').len();
if kept < output.len() {
output.truncate(kept);
output.push('\n');
}
Ok(Some(output))
}
fn build_context(
document: &Document,
writer: &dyn Writer,
body: &str,
options: &WriterOptions,
) -> Result<Value> {
let line_oriented = writer.body_ends_with_newline();
let context_trailing = if line_oriented { "\n\n" } else { "" };
let json_trailing = if line_oriented { "\n" } else { "" };
let context_mode = if writer.flatten_block_metadata() {
BlockMode::Inline
} else {
BlockMode::Full {
trailing: context_trailing,
}
};
let json_mode = BlockMode::Full {
trailing: json_trailing,
};
let mut context: BTreeMap<String, Value> = BTreeMap::new();
let mut meta_json = serde_json::Map::new();
for (key, value) in &document.meta {
let context_value = meta_to_value(value, writer, options, context_mode)?;
let json = if context_mode == json_mode {
value_to_json(&context_value)
} else {
value_to_json(&meta_to_value(value, writer, options, json_mode)?)
};
meta_json.insert(key.clone(), json);
context.insert(key.clone(), context_value);
}
context.insert(
"meta-json".to_owned(),
Value::Str(serde_json::Value::Object(meta_json).to_string()),
);
let body = if line_oriented && !body.is_empty() {
format!("{body}\n\n")
} else {
body.to_owned()
};
context.insert("body".to_owned(), Value::Str(body));
insert_identity_vars(&mut context, document, writer, options)?;
insert_output_vars(&mut context, document, writer, options)?;
if let Some(block) = writer.title_block(document, options)? {
context.insert("titleblock".to_owned(), Value::Str(block));
}
overlay_variables(&mut context, &options.variables);
enable_colorlinks(&mut context);
Ok(Value::Map(context))
}
fn enable_colorlinks(context: &mut BTreeMap<String, Value>) {
if context.get("colorlinks").is_some_and(Value::is_truthy) {
return;
}
let any_color = [
"linkcolor",
"filecolor",
"citecolor",
"urlcolor",
"toccolor",
]
.iter()
.any(|key| context.get(*key).is_some_and(Value::is_truthy));
if any_color {
context.insert("colorlinks".to_owned(), Value::Bool(true));
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum BlockMode<'a> {
Full { trailing: &'a str },
Inline,
}
fn meta_to_value(
value: &MetaValue,
writer: &dyn Writer,
options: &WriterOptions,
mode: BlockMode,
) -> Result<Value> {
Ok(match value {
MetaValue::MetaBool(b) => Value::Bool(*b),
MetaValue::MetaString(text) => {
Value::Str(writer.render_meta_inlines(&[Inline::Str(text.clone())], options)?)
}
MetaValue::MetaInlines(inlines) => {
Value::Str(writer.render_meta_inlines(inlines, options)?)
}
MetaValue::MetaBlocks(blocks) => match mode {
BlockMode::Full { trailing } => {
let mut rendered = writer.render_meta_blocks(blocks, options)?;
if !rendered.is_empty() {
rendered.push_str(trailing);
}
Value::Str(rendered)
}
BlockMode::Inline => {
Value::Str(writer.render_meta_inlines(single_block_inlines(blocks), options)?)
}
},
MetaValue::MetaList(items) => Value::List(
items
.iter()
.map(|item| meta_to_value(item, writer, options, mode))
.collect::<Result<_>>()?,
),
MetaValue::MetaMap(map) => {
let mut entries = BTreeMap::new();
for (key, item) in map {
entries.insert(key.clone(), meta_to_value(item, writer, options, mode)?);
}
Value::Map(entries)
}
})
}
fn value_to_json(value: &Value) -> serde_json::Value {
match value {
Value::Str(text) => serde_json::Value::String(text.clone()),
Value::Bool(flag) => serde_json::Value::Bool(*flag),
Value::List(items) => serde_json::Value::Array(items.iter().map(value_to_json).collect()),
Value::Map(map) => serde_json::Value::Object(
map.iter()
.map(|(key, item)| (key.clone(), value_to_json(item)))
.collect(),
),
}
}
fn insert_identity_vars(
context: &mut BTreeMap<String, Value>,
document: &Document,
writer: &dyn Writer,
options: &WriterOptions,
) -> Result<()> {
let title_text = plain_meta(document, "title");
let title = plain_meta_inlines(document, "title");
let authors = author_plain_inlines(document);
let date_text = plain_meta(document, "date");
let date = plain_meta_inlines(document, "date");
match writer.meta_var_style() {
MetaVarStyle::None => {}
MetaVarStyle::Web => {
let page = if title_text.is_empty() {
options
.source_name
.clone()
.filter(|name| !name.is_empty())
.map(|name| vec![Inline::Str(name)])
} else {
Some(title)
};
if let Some(page) = page {
context.insert(
"pagetitle".to_owned(),
Value::Str(writer.render_meta_inlines(&page, options)?),
);
}
if !date_text.is_empty() {
context.insert(
"date-meta".to_owned(),
Value::Str(writer.render_meta_inlines(&date, options)?),
);
}
let mut list = Vec::with_capacity(authors.len());
for author in &authors {
list.push(Value::Str(writer.render_meta_inlines(author, options)?));
}
context.insert("author-meta".to_owned(), Value::List(list));
}
MetaVarStyle::Pdf => {
context.insert(
"title-meta".to_owned(),
Value::Str(writer.render_meta_inlines(&title, options)?),
);
let mut rendered = Vec::with_capacity(authors.len());
for author in &authors {
rendered.push(writer.render_meta_inlines(author, options)?);
}
context.insert("author-meta".to_owned(), Value::Str(rendered.join("; ")));
}
}
Ok(())
}
fn insert_output_vars(
context: &mut BTreeMap<String, Value>,
document: &Document,
writer: &dyn Writer,
options: &WriterOptions,
) -> Result<()> {
if options.toc {
let depth = options.toc_depth.unwrap_or(3);
match writer.toc_style() {
TocStyle::Native => {
context.insert("toc".to_owned(), Value::Bool(true));
}
TocStyle::List => {
let toc = build_toc(
&document.blocks,
depth,
options.number_sections,
writer.toc_link_anchors(),
);
if let Some(block) = toc {
let rendered = writer.render_meta_blocks(&[block], options)?;
context.insert("toc".to_owned(), Value::Str(rendered));
}
}
}
context.insert("toc-depth".to_owned(), Value::Str(depth.to_string()));
}
if options.number_sections && writer.numbers_sections_natively() {
context.insert("numbersections".to_owned(), Value::Bool(true));
}
match &options.math_method {
MathMethod::Plain => {}
MathMethod::MathJax(url) => {
context.insert("mathjax".to_owned(), Value::Bool(true));
context.insert("mathjaxurl".to_owned(), Value::Str(url.clone()));
}
MathMethod::Katex(url) => {
context.insert("katex".to_owned(), Value::Bool(true));
context.insert("katexurl".to_owned(), Value::Str(url.clone()));
}
}
Ok(())
}
fn plain_meta(document: &Document, key: &str) -> String {
match document.meta.get(key) {
Some(MetaValue::MetaInlines(inlines)) => to_plain_text(inlines),
Some(MetaValue::MetaString(text)) => text.clone(),
Some(MetaValue::MetaBlocks(blocks)) => to_plain_text(single_block_inlines(blocks)),
_ => String::new(),
}
}
fn plain_meta_inlines(document: &Document, key: &str) -> Vec<Inline> {
match document.meta.get(key) {
Some(MetaValue::MetaInlines(inlines)) => to_plain_inlines(inlines),
Some(MetaValue::MetaString(text)) if !text.is_empty() => vec![Inline::Str(text.clone())],
Some(MetaValue::MetaBlocks(blocks)) => to_plain_inlines(single_block_inlines(blocks)),
_ => Vec::new(),
}
}
fn author_plain_inlines(document: &Document) -> Vec<Vec<Inline>> {
fn plain_one(value: &MetaValue) -> (String, Vec<Inline>) {
match value {
MetaValue::MetaInlines(inlines) => (to_plain_text(inlines), to_plain_inlines(inlines)),
MetaValue::MetaString(text) => (text.clone(), vec![Inline::Str(text.clone())]),
MetaValue::MetaBlocks(blocks) => {
let inlines = single_block_inlines(blocks);
(to_plain_text(inlines), to_plain_inlines(inlines))
}
_ => (String::new(), Vec::new()),
}
}
let entries: Vec<(String, Vec<Inline>)> = match document.meta.get("author") {
Some(MetaValue::MetaList(items)) => items.iter().map(plain_one).collect(),
Some(value) => vec![plain_one(value)],
None => Vec::new(),
};
entries
.into_iter()
.filter(|(text, _)| !text.is_empty())
.map(|(_, inlines)| inlines)
.collect()
}
fn overlay_variables(context: &mut BTreeMap<String, Value>, variables: &[(String, String)]) {
let mut overlay: BTreeMap<String, Value> = BTreeMap::new();
for (key, val) in variables {
let next = match overlay.remove(key) {
None => Value::Str(val.clone()),
Some(Value::List(mut items)) => {
items.push(Value::Str(val.clone()));
Value::List(items)
}
Some(first) => Value::List(vec![first, Value::Str(val.clone())]),
};
overlay.insert(key.clone(), next);
}
for (key, value) in overlay {
context.insert(key, value);
}
}
fn resolve_partial(dir: Option<&Path>, ext: &str, name: &str) -> Option<String> {
let dir = dir?;
let filename = if ext.is_empty() || Path::new(name).extension().is_some() {
name.to_owned()
} else {
format!("{name}.{ext}")
};
std::fs::read_to_string(dir.join(filename)).ok()
}