zim-studio 1.5.0

A Terminal-Based Audio Project Scaffold and Metadata System
Documentation
//! Assemble a complete LaTeX document from a walked tree of markdown.
//!
//! Each [`DirNode`] becomes one `\section`. The root section has no leading
//! `\clearpage`; every subsequent directory does. A directory's `README.md`
//! flows as the section's lead body (its own H1 already consumed as the
//! section title). Each non-README markdown file becomes a `\subsection`,
//! titled from YAML `title:`, then the file's first H1, then the file stem.

use super::latex::{PreparedFile, escape, prepare_file};
use super::walk::DirNode;
use std::error::Error;
use std::path::Path;

/// User-facing options for the document.
pub struct PdfOptions {
    pub title: String,
    pub author: Option<String>,
}

const PREAMBLE: &str = r#"\documentclass[11pt]{article}
\usepackage[margin=1in]{geometry}
\usepackage{hyperref}
\IfFileExists{parskip.sty}{\usepackage{parskip}}{}
\providecommand{\sout}[1]{#1}
\IfFileExists{ulem.sty}{\usepackage{ulem}}{}
\setcounter{secnumdepth}{0}
\setcounter{tocdepth}{2}
\hypersetup{colorlinks=true, linkcolor=black, urlcolor=blue}
"#;

/// Build the full LaTeX source string.
pub fn build_latex(tree: &[DirNode], opts: &PdfOptions) -> Result<String, Box<dyn Error>> {
    let mut s = String::with_capacity(8 * 1024);
    s.push_str(PREAMBLE);
    s.push_str(&format!("\\title{{{}}}\n", escape(&opts.title)));
    if let Some(a) = &opts.author {
        s.push_str(&format!("\\author{{{}}}\n", escape(a)));
    } else {
        s.push_str("\\date{}\n");
    }
    s.push_str("\\begin{document}\n\\maketitle\n\\tableofcontents\n");

    for (i, dir) in tree.iter().enumerate() {
        if i > 0 {
            s.push_str("\\clearpage\n");
        }
        emit_directory(&mut s, dir)?;
    }

    s.push_str("\\end{document}\n");
    Ok(s)
}

fn emit_directory(out: &mut String, dir: &DirNode) -> Result<(), Box<dyn Error>> {
    // Heading shifts: README's body sits inside a section, so its leftover
    // H2 lands on a subsection. Sidecar bodies sit inside a subsection, so
    // their leftover H1 lands on a subsubsection.
    let readme_prepared = match &dir.readme {
        Some(p) => Some(prepare_file(p, 1)?),
        None => None,
    };

    let section_title = section_title(dir, readme_prepared.as_ref());
    out.push_str(&format!("\\section{{{}}}\n", escape(&section_title)));

    if let Some(prep) = &readme_prepared {
        out.push_str(&prep.body_latex);
        if !prep.body_latex.ends_with('\n') {
            out.push('\n');
        }
    }

    for md in &dir.other_md {
        let prep = prepare_file(md, 2)?;
        let sub_title = subsection_title(md, &prep);
        out.push_str(&format!("\\subsection{{{}}}\n", escape(&sub_title)));
        out.push_str(&prep.body_latex);
        if !prep.body_latex.ends_with('\n') {
            out.push('\n');
        }
    }
    Ok(())
}

fn section_title(dir: &DirNode, readme: Option<&PreparedFile>) -> String {
    if let Some(prep) = readme
        && let Some(t) = &prep.first_h1
        && !t.is_empty()
    {
        return t.clone();
    }
    if let Some(prep) = readme
        && let Some(t) = &prep.frontmatter_title
        && !t.is_empty()
    {
        return t.clone();
    }
    if dir.is_root() {
        "Overview".to_string()
    } else {
        dir.rel_path.to_string_lossy().into_owned()
    }
}

fn subsection_title(path: &Path, prep: &PreparedFile) -> String {
    if let Some(t) = &prep.frontmatter_title
        && !t.is_empty()
    {
        return t.clone();
    }
    if let Some(t) = &prep.first_h1
        && !t.is_empty()
    {
        return t.clone();
    }
    path.file_stem()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_else(|| "Untitled".to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::PathBuf;
    use tempfile::TempDir;

    fn touch(p: &PathBuf, body: &str) {
        if let Some(parent) = p.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(p, body).unwrap();
    }

    #[test]
    fn root_has_no_clearpage_subdirs_do() {
        let tmp = TempDir::new().unwrap();
        let root_readme = tmp.path().join("README.md");
        touch(&root_readme, "# Top\n\nintro\n");
        let mixes_readme = tmp.path().join("mixes/README.md");
        touch(&mixes_readme, "# Mixes\n\nconcepts\n");
        let track = tmp.path().join("mixes/track.md");
        touch(&track, "---\ntitle: Track One\n---\n\nbody\n");

        let tree =
            super::super::walk::walk(tmp.path(), &crate::zimignore::ZimIgnore::new()).unwrap();
        let tex = build_latex(
            &tree,
            &PdfOptions {
                title: "Demo".into(),
                author: None,
            },
        )
        .unwrap();

        // Root section first, no preceding clearpage
        let root_pos = tex.find("\\section{Top}").expect("root section present");
        let pre_root = &tex[..root_pos];
        assert!(!pre_root.contains("\\clearpage"));

        // Subdirectory section preceded by clearpage
        let mixes_pos = tex.find("\\section{Mixes}").expect("mixes section present");
        let between = &tex[root_pos..mixes_pos];
        assert!(between.contains("\\clearpage"));

        // Subsection title comes from frontmatter
        assert!(tex.contains("\\subsection{Track One}"));
    }

    #[test]
    fn fallback_section_title_uses_dir_name() {
        let tmp = TempDir::new().unwrap();
        touch(&tmp.path().join("only/track.md"), "body\n");
        let tree =
            super::super::walk::walk(tmp.path(), &crate::zimignore::ZimIgnore::new()).unwrap();
        let tex = build_latex(
            &tree,
            &PdfOptions {
                title: "Demo".into(),
                author: None,
            },
        )
        .unwrap();
        assert!(tex.contains("\\section{only}"));
        assert!(tex.contains("\\subsection{track}"));
    }
}