use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use tempfile::TempDir;
use tokio::process::Command;
use std::path::Path;
use crate::AssetProvider;
use crate::assets::BoxFuture;
use crate::images::resolve_images;
use crate::pages::Page;
use crate::{Artifact, Backend, RenderRequest, Target};
async fn materialise_subtree(
provider: &dyn AssetProvider,
prefix: &str,
dest: &Path,
) -> Result<usize> {
let keys = provider.list(prefix).await?;
let mut written = 0usize;
for key in keys {
let Some(bytes) = provider.get(&key).await? else { continue };
let rel = key.strip_prefix(prefix).unwrap_or(&key);
let path = dest.join(rel);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(&path, &bytes).await?;
written += 1;
}
Ok(written)
}
pub struct PandocBackend {
target: Target,
}
impl PandocBackend {
pub fn new(target: Target) -> Self {
assert!(matches!(
target,
Target::Docx | Target::Odt | Target::Pptx | Target::HtmlReveal
));
Self { target }
}
}
impl Backend for PandocBackend {
fn target(&self) -> Target {
self.target
}
fn render<'a>(&'a self, req: &'a RenderRequest<'a>) -> BoxFuture<'a, Result<Artifact>> {
Box::pin(async move {
let tmp = TempDir::new().context("create temp dir for pandoc render")?;
let root = tmp.path();
let mut pages = req.doc.pages.clone();
let assets_dir = root.join("assets");
tokio::fs::create_dir_all(&assets_dir).await?;
resolve_images(&mut pages, req.assets, &assets_dir).await?;
let input = build_input(&pages, self.target);
let input_path = root.join("input.md");
tokio::fs::write(&input_path, input).await?;
let reference = match self.target {
Target::Docx => Some(("reference/reference.docx", "reference.docx")),
Target::Odt => Some(("reference/reference.odt", "reference.odt")),
Target::Pptx => Some(("reference/reference.pptx", "reference.pptx")),
Target::HtmlReveal => None,
_ => unreachable!(),
};
let reference_path = if let Some((key, name)) = reference {
if let Some(bytes) = req.assets.get(key).await? {
let p = root.join(name);
tokio::fs::write(&p, &bytes).await?;
Some(p)
} else {
tracing::warn!(key, "reference doc not in provider; pandoc default styling will be used");
None
}
} else {
None
};
let out_path: PathBuf = req.out.to_path_buf();
if let Some(parent) = out_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let revealjs_root = if matches!(self.target, Target::HtmlReveal) {
let dest = root.join("revealjs");
let materialised =
materialise_subtree(req.assets, "revealjs/", &dest).await?;
if materialised > 0 { Some(dest) } else { None }
} else {
None
};
let mut cmd = Command::new("pandoc");
cmd.arg(&input_path);
cmd.arg("-o").arg(&out_path);
cmd.arg("--from=markdown");
cmd.arg(format!("--to={}", pandoc_writer(self.target)));
if matches!(self.target, Target::HtmlReveal) {
cmd.arg("--standalone").arg("--embed-resources");
if let Some(dir) = &revealjs_root {
cmd.arg(format!("-Vrevealjs-url={}/", dir.display()));
}
}
if matches!(self.target, Target::Pptx | Target::HtmlReveal) {
cmd.arg("--slide-level=1");
}
if let Some(p) = &reference_path {
cmd.arg(format!("--reference-doc={}", p.display()));
}
if let Some(t) = &req.doc.meta.title {
cmd.arg(format!("--metadata=title={t}"));
}
if let Some(a) = &req.doc.meta.author {
cmd.arg(format!("--metadata=author={a}"));
}
if let Some(d) = &req.doc.meta.date {
cmd.arg(format!("--metadata=date={d}"));
}
let status = cmd
.status()
.await
.context("spawn pandoc — is the `pandoc` binary on PATH?")?;
if !status.success() {
bail!("pandoc failed with status {status}");
}
Ok(Artifact { primary: out_path, extras: vec![] })
})
}
}
fn pandoc_writer(target: Target) -> &'static str {
match target {
Target::Docx => "docx",
Target::Odt => "odt",
Target::Pptx => "pptx",
Target::HtmlReveal => "revealjs",
_ => unreachable!(),
}
}
pub fn build_input(pages: &[Page], target: Target) -> String {
let mut s = String::new();
for (i, page) in pages.iter().enumerate() {
if i > 0 {
s.push('\n');
if matches!(target, Target::Docx | Target::Odt) {
s.push_str("\\pagebreak{}\n\n");
}
}
match target {
Target::Docx | Target::Odt => {
s.push_str(&format!("::: {{custom-style=\"{}\"}}\n", page.class));
s.push_str(&page.body);
if !page.body.ends_with('\n') {
s.push('\n');
}
s.push_str(":::\n");
}
Target::Pptx | Target::HtmlReveal => {
s.push_str(&project_slide(&page.body, &page.class));
if !s.ends_with('\n') {
s.push('\n');
}
}
_ => unreachable!(),
}
}
s
}
fn project_slide(body: &str, class: &str) -> String {
let trimmed = body.trim_start_matches('\n');
let mut lines = trimmed.lines();
let Some(first) = lines.next() else {
return format!("# {{.{class}}}\n");
};
if let Some(title) = first.strip_prefix("# ") {
let rest: String = lines.collect::<Vec<_>>().join("\n");
let trailing_nl = if body.ends_with('\n') { "\n" } else { "" };
if rest.is_empty() {
format!("# {title} {{.{class}}}\n")
} else {
format!("# {title} {{.{class}}}\n{rest}{trailing_nl}")
}
} else {
let trailing_nl = if body.ends_with('\n') { "" } else { "\n" };
format!("# {{.{class}}}\n\n{body}{trailing_nl}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pages::{Page, PageOrigin};
fn p(class: &str, body: &str) -> Page {
Page { class: class.into(), body: body.into(), origin: PageOrigin::Explicit }
}
#[test]
fn slide_input_attaches_class_to_existing_h1() {
let out = build_input(
&[p("hero", "# Title\n\nsub"), p("content", "no heading body")],
Target::HtmlReveal,
);
assert!(out.contains("# Title {.hero}"), "{out}");
assert!(out.contains("# {.content}"), "{out}");
}
#[test]
fn page_input_uses_pagebreak_for_docx() {
let out = build_input(&[p("hero", "intro"), p("content", "body")], Target::Docx);
assert!(out.contains(r"\pagebreak{}"));
assert!(out.contains(r#"custom-style="hero""#));
assert!(out.contains(r#"custom-style="content""#));
}
#[test]
fn single_page_has_no_separator() {
let out = build_input(&[p("hero", "just one")], Target::HtmlReveal);
assert!(!out.contains(r"\pagebreak"));
assert!(out.starts_with("# {.hero}"));
}
}