use anyhow::{Context, Result, bail};
use bytes::Bytes;
use tempfile::TempDir;
use tokio::process::Command;
use crate::AssetProvider;
use crate::assets::BoxFuture;
use crate::images::resolve_images;
use crate::pages::Page;
use crate::{Backend, RenderedArtifact, ResolvedDoc, Target};
use super::reveal_assets::{brand_logo_file, brand_style_file, materialise_subtree};
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_to_bytes<'a>(
&'a self,
doc: &'a ResolvedDoc,
assets: &'a dyn AssetProvider,
) -> BoxFuture<'a, Result<RenderedArtifact>> {
Box::pin(async move {
let tmp = TempDir::new().context("create temp dir for pandoc render")?;
let root = tmp.path();
let mut pages = doc.pages.clone();
let assets_dir = root.join("assets");
tokio::fs::create_dir_all(&assets_dir).await?;
resolve_images(&mut pages, assets, &assets_dir, self.target).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) = 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 filename = format!("output.{}", self.target.extension());
let out_path = root.join(&filename);
let revealjs_root = if matches!(self.target, Target::HtmlReveal) {
let dest = root.join("revealjs");
let materialised = materialise_subtree(assets, "revealjs/", &dest).await?;
if materialised > 0 { Some(dest) } else { None }
} else {
None
};
let style_path = if matches!(self.target, Target::HtmlReveal) {
brand_style_file(root, &doc.brand.0, assets).await?
} else {
None
};
let logo_path = if matches!(self.target, Target::HtmlReveal) {
brand_logo_file(root, &doc.brand.0, assets).await?
} 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 let Some(p) = &style_path {
cmd.arg(format!("--include-in-header={}", p.display()));
}
if let Some(p) = &logo_path {
cmd.arg(format!("--include-after-body={}", p.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()));
}
cmd.args(toc_args(self.target, doc.toc));
if let Some(t) = &doc.meta.title {
cmd.arg(format!("--metadata=title={t}"));
}
if let Some(a) = &doc.meta.author {
cmd.arg(format!("--metadata=author={a}"));
}
if let Some(d) = &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}");
}
let mut bytes = tokio::fs::read(&out_path)
.await
.context("read pandoc output")?;
if matches!(self.target, Target::Pptx) {
bytes = super::pptx_autofit::add_autofit(&bytes)
.context("patch pptx with normAutofit")?;
}
Ok(RenderedArtifact {
primary: Bytes::from(bytes),
filename,
extras: vec![],
})
})
}
}
fn toc_args(target: Target, toc: Option<u8>) -> Vec<String> {
match (target, toc) {
(Target::Docx | Target::Odt, Some(depth)) => {
vec!["--toc".to_string(), format!("--toc-depth={depth}")]
}
_ => 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');
match target {
Target::Docx => {
s.push_str(
"```{=openxml}\n<w:p><w:r><w:br w:type=\"page\"/></w:r></w:p>\n```\n\n",
);
}
Target::Odt => {
s.push_str(
"```{=opendocument}\n<text:p text:style-name=\"PageBreak\"/>\n```\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)]
#[path = "pandoc_tests.rs"]
mod tests;