fur_cli/renderer/
pdf.rs

1use std::fs::{self, File};
2use std::io::Write;
3use std::path::Path;
4use std::process::Command;
5use serde_json::Value;
6
7use crate::commands::timeline::TimelineArgs;
8use crate::renderer::utils::load_message;
9
10/// LaTeX preamble with fixes for Pandoc output + math + images
11fn latex_preamble(thread_title: &str) -> String {
12    format!(
13r#"\documentclass[12pt]{{article}}
14\usepackage[margin=1in]{{geometry}}
15\usepackage{{parskip}}
16\usepackage{{xcolor}}
17\usepackage{{titlesec}}
18\usepackage{{amsmath}}       % better math
19\usepackage{{hyperref}}
20\usepackage[T1]{{fontenc}}
21\usepackage[utf8]{{inputenc}}
22\usepackage{{lmodern}}
23\usepackage{{graphicx}}
24
25% Pandoc fix for lists
26\providecommand{{\tightlist}}{{%
27  \setlength{{\itemsep}}{{0pt}}\setlength{{\parskip}}{{0pt}}}}
28
29% Message macro
30\newcommand{{\MessageBlock}}[3]{{
31  \vspace{{1em}}
32  \noindent\textbf{{#1}} \hfill \textit{{#2}} \\
33  #3
34  \par
35}}
36
37\begin{{document}}
38\begin{{center}}
39    \LARGE\bfseries {title} \\
40    \rule{{\linewidth}}{{0.4pt}}
41\end{{center}}
42"#,
43        title = thread_title
44    )
45}
46
47/// Document ending
48fn latex_end() -> &'static str {
49    r#"\end{document}"#
50}
51
52/// Strip emojis and other non-ASCII that break pdflatex
53fn strip_emojis(input: &str) -> String {
54    input.chars().filter(|c| c.is_ascii() || c.is_alphanumeric() || c.is_whitespace()).collect()
55}
56
57/// Render a single message (recursively) into LaTeX
58pub fn render_message_tex(
59    fur_dir: &Path,
60    msg_id: &str,
61    label: String,        // e.g. "Root", "Root - Branch 1"
62    args: &TimelineArgs,
63    avatars: &Value,
64    tex_out: &mut File,
65    depth: usize,         // branch depth
66) {
67    let Some(msg) = load_message(fur_dir, msg_id, avatars) else { return };
68
69    // Escape LaTeX special characters
70    let escape = |s: &str| {
71            s.replace("&", "\\&")
72            .replace("%", "\\%")
73            .replace("$", "\\$")
74            .replace("#", "\\#")
75            .replace("_", "\\_")
76            .replace("{", "\\{")
77            .replace("}", "\\}")
78            .replace("~", "\\textasciitilde{}")
79            .replace("^", "\\textasciicircum{}")
80            .replace("\n", " \\\\\n")
81    };
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(&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(&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(&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(&msg.text))
118        }
119    } else {
120        escape(&strip_emojis(&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")     // ✅ allow PDFs
130        {
131            full_content += &format!(
132                "\n\\begin{{center}}\\includegraphics[width=0.9\\linewidth]{{{}}}\\end{{center}}\n",
133                att
134            );
135        } else {
136            full_content += &format!("\n[Attachment: {}]\n", att);
137        }
138    }
139
140    writeln!(
141        tex_out,
142        "\\MessageBlock{{{}}}{{{} {} - {}}}{{{}}}",
143        escape(&msg.name),
144        msg.date_str,
145        msg.time_str,
146        label,
147        full_content
148    )
149    .unwrap();
150
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_message_tex(fur_dir, cid, branch_label.clone(), args, avatars, tex_out, depth + 1);
158        }
159    }
160}
161
162
163/// Export a full thread to LaTeX and compile to PDF
164pub fn export_to_pdf(
165    fur_dir: &Path,
166    thread_title: &str,
167    root_msgs: &[Value],
168    args: &TimelineArgs,
169    avatars: &Value,
170    out_path: &str,
171) {
172    let tex_file = out_path.replace(".pdf", ".tex");
173    let mut file = File::create(&tex_file).expect("❌ Failed to create .tex file");
174
175    // Write preamble
176    file.write_all(latex_preamble(thread_title).as_bytes()).unwrap();
177
178    // Write messages
179    for mid in root_msgs {
180        if let Some(mid_str) = mid.as_str() {
181            render_message_tex(fur_dir, mid_str, "Root".to_string(), args, avatars, &mut file, 0);
182        }
183    }
184
185    // End document
186    file.write_all(latex_end().as_bytes()).unwrap();
187
188    // Compile with pdflatex
189    Command::new("pdflatex")
190        .arg("-interaction=nonstopmode")
191        .arg(&tex_file)
192        .status()
193        .expect("❌ Failed to run pdflatex");
194
195    println!("✔️ Exported LaTeX to {}", out_path);
196}