Skip to main content

fur_cli/renderer/
pdf.rs

1use serde_json::Value;
2use std::fs::{self, File};
3use std::io::Write;
4use std::path::Path;
5use std::process::Command;
6
7use crate::commands::timeline::TimelineArgs;
8use crate::renderer::utils::load_message;
9
10fn latex_preamble(conversation_title: &str) -> String {
11    format!(
12        r#"\documentclass[12pt]{{article}}
13\usepackage[margin=1in]{{geometry}}
14\usepackage{{parskip}}
15\usepackage{{xcolor}}
16\usepackage{{titlesec}}
17\usepackage{{amsmath}}       % better math
18\usepackage{{hyperref}}
19\usepackage[T1]{{fontenc}}
20\usepackage[utf8]{{inputenc}}
21\usepackage{{lmodern}}
22\usepackage{{graphicx}}
23
24% Pandoc fix for lists
25\providecommand{{\tightlist}}{{%
26  \setlength{{\itemsep}}{{0pt}}\setlength{{\parskip}}{{0pt}}}}
27
28% Message macro
29\newcommand{{\MessageBlock}}[3]{{
30  \vspace{{1em}}
31  \noindent\textbf{{#1}} \hfill \textit{{#2}} \\
32  #3
33  \par
34}}
35
36\begin{{document}}
37\begin{{center}}
38    \LARGE\bfseries {title} \\
39    \rule{{\linewidth}}{{0.4pt}}
40\end{{center}}
41"#,
42        title = conversation_title
43    )
44}
45
46fn latex_ending() -> &'static str {
47    r#"\end{document}"#
48}
49
50fn strip_emojis_n_nonascii(input: &str) -> String {
51    input
52        .chars()
53        .filter(|c| c.is_ascii() || c.is_alphanumeric() || c.is_whitespace())
54        .collect()
55}
56
57pub fn render_single_message_to_tex(
58    fur_dir: &Path,
59    msg_id: &str,
60    label: String, // e.g. "Root", "Root - Branch 1"
61    args: &TimelineArgs,
62    avatars: &Value,
63    tex_out: &mut File,
64    depth: usize, // branch depth
65) {
66    let Some(msg) = load_message(fur_dir, msg_id, avatars) else {
67        return;
68    };
69
70    // Escape LaTeX special characters
71    let escape = |s: &str| {
72        s.replace("&", "\\&")
73            .replace("%", "\\%")
74            .replace("$", "\\$")
75            .replace("#", "\\#")
76            .replace("_", "\\_")
77            .replace("{", "\\{")
78            .replace("}", "\\}")
79            .replace("~", "\\textasciitilde{}")
80            .replace("^", "\\textasciicircum{}")
81            .replace("\n", " \\\\\n")
82    };
83
84    // Handle message content safely
85    let base_content = if args.verbose || args.contents {
86        if let Some(path_str) = msg.markdown.clone() {
87            let mut out = String::new();
88
89            // Always show text if it's non-empty
90            if !msg.text.trim().is_empty() {
91                out += &format!("{}\n\n", escape(&strip_emojis_n_nonascii(&msg.text)));
92            }
93
94            // Try to render markdown as LaTeX
95            match Command::new("pandoc")
96                .args(&["-f", "markdown", "-t", "latex", &path_str])
97                .output()
98            {
99                Ok(output) if output.status.success() => {
100                    let latex_body = String::from_utf8_lossy(&output.stdout);
101                    out += &format!(
102                        "Attached document:\n\n\\begin{{quote}}\n{}\n\\end{{quote}}\n\\clearpage",
103                        strip_emojis_n_nonascii(&latex_body)
104                    );
105                }
106                _ => {
107                    // Fallback to raw contents if Pandoc fails
108                    let fallback = fs::read_to_string(path_str)
109                        .map(|s| escape(&strip_emojis_n_nonascii(&s)))
110                        .unwrap_or_else(|_| String::from("[Markdown file missing]"));
111                    out += &format!("{}\n\\clearpage", fallback);
112                }
113            }
114
115            out
116        } else {
117            escape(&strip_emojis_n_nonascii(&msg.text))
118        }
119    } else {
120        escape(&strip_emojis_n_nonascii(&msg.text))
121    };
122
123    let mut full_content = base_content.clone();
124
125    if let Some(att) = msg.attachment.clone() {
126        if att.ends_with(".png")
127            || att.ends_with(".jpg")
128            || att.ends_with(".jpeg")
129            || att.ends_with(".pdf")
130        // ✅ allow PDFs
131        {
132            full_content += &format!(
133                "\n\\begin{{center}}\\includegraphics[width=0.9\\linewidth]{{{}}}\\end{{center}}\n",
134                att
135            );
136        } else {
137            full_content += &format!("\n[Attachment: {}]\n", att);
138        }
139    }
140
141    writeln!(
142        tex_out,
143        "\\MessageBlock{{{}}}{{{} {} - {}}}{{{}}}",
144        escape(&msg.name),
145        msg.date_str,
146        msg.time_str,
147        label,
148        full_content
149    )
150    .unwrap();
151
152    // ✅ Recurse branch-aware
153    for (bi, block) in msg.branches.iter().enumerate() {
154        let branch_label = format!("{} - Branch {}", label, bi + 1);
155
156        for cid in block {
157            render_single_message_to_tex(
158                fur_dir,
159                cid,
160                branch_label.clone(),
161                args,
162                avatars,
163                tex_out,
164                depth + 1,
165            );
166        }
167    }
168}
169
170pub fn export_convo_to_pdf(
171    fur_dir: &Path,
172    conversation_title: &str,
173    root_msgs: &[Value],
174    args: &TimelineArgs,
175    avatars: &Value,
176    out_path: &str,
177) {
178    let tex_file = out_path.replace(".pdf", ".tex");
179    let mut file = File::create(&tex_file).expect("❌ Failed to create .tex file");
180
181    // Write preamble
182    file.write_all(latex_preamble(conversation_title).as_bytes())
183        .unwrap();
184
185    // Write messages
186    for mid in root_msgs {
187        if let Some(mid_str) = mid.as_str() {
188            render_single_message_to_tex(
189                fur_dir,
190                mid_str,
191                "Root".to_string(),
192                args,
193                avatars,
194                &mut file,
195                0,
196            );
197        }
198    }
199
200    // End document
201    file.write_all(latex_ending().as_bytes()).unwrap();
202
203    // Compile with pdflatex
204    Command::new("pdflatex")
205        .arg("-interaction=nonstopmode")
206        .arg(&tex_file)
207        .status()
208        .expect("❌ Failed to run pdflatex");
209
210    println!("✔️ Exported LaTeX to {}", out_path);
211}