Skip to main content

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
10fn latex_preamble(conversation_title: &str) -> String {
11    format!(
12r#"\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.chars().filter(|c| c.is_ascii() || c.is_alphanumeric() || c.is_whitespace()).collect()
52}
53
54pub fn render_single_message_to_tex(
55    fur_dir: &Path,
56    msg_id: &str,
57    label: String,        // e.g. "Root", "Root - Branch 1"
58    args: &TimelineArgs,
59    avatars: &Value,
60    tex_out: &mut File,
61    depth: usize,         // branch depth
62) {
63    let Some(msg) = load_message(fur_dir, msg_id, avatars) else { return };
64
65    // Escape LaTeX special characters
66    let escape = |s: &str| {
67            s.replace("&", "\\&")
68            .replace("%", "\\%")
69            .replace("$", "\\$")
70            .replace("#", "\\#")
71            .replace("_", "\\_")
72            .replace("{", "\\{")
73            .replace("}", "\\}")
74            .replace("~", "\\textasciitilde{}")
75            .replace("^", "\\textasciicircum{}")
76            .replace("\n", " \\\\\n")
77    };
78
79
80    // Handle message content safely
81    let base_content = if args.verbose || args.contents {
82        if let Some(path_str) = msg.markdown.clone() {
83            let mut out = String::new();
84
85            // Always show text if it's non-empty
86            if !msg.text.trim().is_empty() {
87                out += &format!("{}\n\n", escape(&strip_emojis_n_nonascii(&msg.text)));
88            }
89
90            // Try to render markdown as LaTeX
91            match Command::new("pandoc")
92                .args(&["-f", "markdown", "-t", "latex", &path_str])
93                .output()
94            {
95                Ok(output) if output.status.success() => {
96                    let latex_body = String::from_utf8_lossy(&output.stdout);
97                    out += &format!(
98                        "Attached document:\n\n\\begin{{quote}}\n{}\n\\end{{quote}}\n\\clearpage",
99                        strip_emojis_n_nonascii(&latex_body)
100                    );
101                }
102                _ => {
103                    // Fallback to raw contents if Pandoc fails
104                    let fallback = fs::read_to_string(path_str)
105                        .map(|s| escape(&strip_emojis_n_nonascii(&s)))
106                        .unwrap_or_else(|_| String::from("[Markdown file missing]"));
107                    out += &format!("{}\n\\clearpage", fallback);
108                }
109            }
110
111            out
112        } else {
113            escape(&strip_emojis_n_nonascii(&msg.text))
114        }
115    } else {
116        escape(&strip_emojis_n_nonascii(&msg.text))
117    };
118
119    let mut full_content = base_content.clone();
120
121    if let Some(att) = msg.attachment.clone() {
122        if att.ends_with(".png")
123            || att.ends_with(".jpg")
124            || att.ends_with(".jpeg")
125            || att.ends_with(".pdf")     // ✅ allow PDFs
126        {
127            full_content += &format!(
128                "\n\\begin{{center}}\\includegraphics[width=0.9\\linewidth]{{{}}}\\end{{center}}\n",
129                att
130            );
131        } else {
132            full_content += &format!("\n[Attachment: {}]\n", att);
133        }
134    }
135
136    writeln!(
137        tex_out,
138        "\\MessageBlock{{{}}}{{{} {} - {}}}{{{}}}",
139        escape(&msg.name),
140        msg.date_str,
141        msg.time_str,
142        label,
143        full_content
144    )
145    .unwrap();
146
147
148    // ✅ Recurse branch-aware
149    for (bi, block) in msg.branches.iter().enumerate() {
150        let branch_label = format!("{} - Branch {}", label, bi + 1);
151
152        for cid in block {
153            render_single_message_to_tex(fur_dir, cid, branch_label.clone(), args, avatars, tex_out, depth + 1);
154        }
155    }
156}
157
158
159pub fn export_convo_to_pdf(
160    fur_dir: &Path,
161    conversation_title: &str,
162    root_msgs: &[Value],
163    args: &TimelineArgs,
164    avatars: &Value,
165    out_path: &str,
166) {
167    let tex_file = out_path.replace(".pdf", ".tex");
168    let mut file = File::create(&tex_file).expect("❌ Failed to create .tex file");
169
170    // Write preamble
171    file.write_all(latex_preamble(conversation_title).as_bytes()).unwrap();
172
173    // Write messages
174    for mid in root_msgs {
175        if let Some(mid_str) = mid.as_str() {
176            render_single_message_to_tex(fur_dir, mid_str, "Root".to_string(), args, avatars, &mut file, 0);
177        }
178    }
179
180    // End document
181    file.write_all(latex_ending().as_bytes()).unwrap();
182
183    // Compile with pdflatex
184    Command::new("pdflatex")
185        .arg("-interaction=nonstopmode")
186        .arg(&tex_file)
187        .status()
188        .expect("❌ Failed to run pdflatex");
189
190    println!("✔️ Exported LaTeX to {}", out_path);
191}