1use anyhow::{anyhow, Result};
4use std::process::Stdio;
5use tokio::process::Command;
6use tokio::time::{timeout, Duration};
7
8const CREATE_NO_WINDOW: u32 = 0x08000000;
9const CONVERT_TIMEOUT_SECS: u64 = 120;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum OfficeKind {
13 Word,
14 Excel,
15 PowerPoint,
16}
17
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
19#[serde(rename_all = "camelCase")]
20pub struct OfficeStatus {
21 pub word: bool,
22 pub excel: bool,
23 pub powerpoint: bool,
24}
25
26pub async fn office_install_status() -> OfficeStatus {
27 const SCRIPT: &str = r#"
28$w = [type]::GetTypeFromProgID('Word.Application')
29$e = [type]::GetTypeFromProgID('Excel.Application')
30$p = [type]::GetTypeFromProgID('PowerPoint.Application')
31Write-Output ('{0},{1},{2}' -f ($null -ne $w),($null -ne $e),($null -ne $p))
32"#;
33
34 let child = match Command::new("powershell")
35 .args([
36 "-NoProfile",
37 "-ExecutionPolicy",
38 "Bypass",
39 "-Command",
40 SCRIPT,
41 ])
42 .stdout(Stdio::piped())
43 .stderr(Stdio::piped())
44 .creation_flags(CREATE_NO_WINDOW)
45 .kill_on_drop(true)
46 .spawn()
47 {
48 Ok(c) => c,
49 Err(_) => return OfficeStatus::default(),
50 };
51
52 let output = match timeout(
53 Duration::from_secs(CONVERT_TIMEOUT_SECS),
54 child.wait_with_output(),
55 )
56 .await
57 {
58 Ok(Ok(o)) => o,
59 _ => return OfficeStatus::default(),
60 };
61
62 let line = String::from_utf8_lossy(&output.stdout)
63 .lines()
64 .next()
65 .unwrap_or("")
66 .trim()
67 .to_string();
68 let mut parts = line.split(',');
69 let parse = |s: Option<&str>| s.map(|v| v.trim().eq_ignore_ascii_case("true")).unwrap_or(false);
70 OfficeStatus {
71 word: parse(parts.next()),
72 excel: parse(parts.next()),
73 powerpoint: parse(parts.next()),
74 }
75}
76
77pub async fn is_office_installed(kind: OfficeKind) -> bool {
78 let status = office_install_status().await;
79 match kind {
80 OfficeKind::Word => status.word,
81 OfficeKind::Excel => status.excel,
82 OfficeKind::PowerPoint => status.powerpoint,
83 }
84}
85
86pub async fn convert_office_to_pdf(input_path: &str, output_path: &str) -> Result<()> {
87 let ps_script = build_powershell_script(input_path, output_path)?;
88
89 let child = Command::new("powershell")
90 .args([
91 "-NoProfile",
92 "-ExecutionPolicy",
93 "Bypass",
94 "-Command",
95 &ps_script,
96 ])
97 .stdout(Stdio::piped())
98 .stderr(Stdio::piped())
99 .creation_flags(CREATE_NO_WINDOW)
100 .kill_on_drop(true)
101 .spawn()
102 .map_err(|e| anyhow!("Failed to start PowerShell: {}", e))?;
103
104 let output = timeout(
105 Duration::from_secs(CONVERT_TIMEOUT_SECS),
106 child.wait_with_output(),
107 )
108 .await
109 .map_err(|_| {
110 anyhow!(
111 "Conversion timed out after {} seconds",
112 CONVERT_TIMEOUT_SECS
113 )
114 })?
115 .map_err(|e| anyhow!("Process execution failed: {}", e))?;
116
117 if output.status.success() && std::path::Path::new(output_path).exists() {
118 Ok(())
119 } else {
120 let stderr = String::from_utf8_lossy(&output.stderr);
121 Err(anyhow!(
122 "Conversion failed (exit code: {:?})\n{}",
123 output.status.code(),
124 stderr.trim()
125 ))
126 }
127}
128
129fn escape_path_for_powershell(path: &str) -> String {
130 path.replace('\'', "''")
131}
132
133fn build_powershell_script(input: &str, output: &str) -> Result<String> {
134 let ext = std::path::Path::new(input)
135 .extension()
136 .and_then(|e| e.to_str())
137 .map(|s| s.to_lowercase())
138 .ok_or_else(|| anyhow!("Unable to get file extension: {}", input))?;
139
140 let input_escaped = escape_path_for_powershell(input);
141 let output_escaped = escape_path_for_powershell(output);
142
143 let script = match ext.as_str() {
144 "doc" | "docx" | "odt" => {
145 format!(
146 r#"
147 $ErrorActionPreference = "Stop"
148 $word = $null
149 $doc = $null
150 try {{
151 $word = New-Object -ComObject Word.Application
152 $word.Visible = $false
153 $word.DisplayAlerts = 0
154 $word.FeatureInstall = 2
155 $word.AutomationSecurity = 3
156 $doc = $word.Documents.Open('{0}', $false, $true)
157 $doc.ExportAsFixedFormat('{1}', 17)
158 }}
159 catch {{
160 Write-Error $_.Exception.Message
161 throw
162 }}
163 finally {{
164 if ($doc -ne $null) {{
165 $doc.Close()
166 [System.Runtime.Interopservices.Marshal]::ReleaseComObject($doc) | Out-Null
167 }}
168 if ($word -ne $null) {{
169 $word.Quit()
170 [System.Runtime.Interopservices.Marshal]::ReleaseComObject($word) | Out-Null
171 }}
172 [System.GC]::Collect()
173 [System.GC]::WaitForPendingFinalizers()
174 }}
175 "#,
176 input_escaped, output_escaped
177 )
178 }
179 "xls" | "xlsx" | "ods" => {
180 format!(
181 r#"
182 $ErrorActionPreference = "Stop"
183 $excel = $null
184 $wb = $null
185 try {{
186 $excel = New-Object -ComObject Excel.Application
187 $excel.Visible = $false
188 $excel.DisplayAlerts = $false
189 $excel.FeatureInstall = 2
190 $excel.AutomationSecurity = 3
191 $wb = $excel.Workbooks.Open('{0}', $false, $true)
192 $wb.ExportAsFixedFormat(0, '{1}')
193 }}
194 catch {{
195 Write-Error $_.Exception.Message
196 throw
197 }}
198 finally {{
199 if ($wb -ne $null) {{
200 $wb.Close()
201 [System.Runtime.Interopservices.Marshal]::ReleaseComObject($wb) | Out-Null
202 }}
203 if ($excel -ne $null) {{
204 $excel.Quit()
205 [System.Runtime.Interopservices.Marshal]::ReleaseComObject($excel) | Out-Null
206 }}
207 [System.GC]::Collect()
208 [System.GC]::WaitForPendingFinalizers()
209 }}
210 "#,
211 input_escaped, output_escaped
212 )
213 }
214 "ppt" | "pptx" | "odp" => {
215 format!(
216 r#"
217 $ErrorActionPreference = "Stop"
218 $ppt = $null
219 $pres = $null
220 $origWindowState = $null
221 $origSecurity = $null
222 try {{
223 $ppt = New-Object -ComObject PowerPoint.Application
224 $origSecurity = $ppt.AutomationSecurity
225 $ppt.AutomationSecurity = 3
226 $origWindowState = $ppt.WindowState
227 $ppt.WindowState = 2
228 $pres = $ppt.Presentations.Open('{0}', 0, 0, 0)
229 try {{
230 if ($ppt.Windows.Count -gt 0) {{
231 $helper = 'using System; using System.Runtime.InteropServices; public class Win32 {{ [DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr hWnd, int nCmdShow); }}'
232 Add-Type -TypeDefinition $helper
233 $hwnd = $ppt.Windows.Item(1).Hwnd
234 if ($hwnd -and $hwnd -ne 0) {{
235 [Win32]::ShowWindow([IntPtr]$hwnd, 0) | Out-Null
236 }}
237 }}
238 }} catch {{
239 }}
240 $pres.SaveAs('{1}', 32)
241 }}
242 catch {{
243 Write-Error $_.Exception.Message
244 throw
245 }}
246 finally {{
247 if ($pres -ne $null) {{
248 $pres.Close()
249 [System.Runtime.Interopservices.Marshal]::ReleaseComObject($pres) | Out-Null
250 }}
251 if ($ppt -ne $null) {{
252 try {{ $ppt.WindowState = $origWindowState }} catch {{ }}
253 try {{ $ppt.AutomationSecurity = $origSecurity }} catch {{ }}
254 if ($ppt.Presentations.Count -eq 0) {{
255 $ppt.Quit()
256 }}
257 [System.Runtime.Interopservices.Marshal]::ReleaseComObject($ppt) | Out-Null
258 }}
259 [System.GC]::Collect()
260 [System.GC]::WaitForPendingFinalizers()
261 }}
262 "#,
263 input_escaped, output_escaped
264 )
265 }
266
267 _ => return Err(anyhow!("Unsupported file format: {}", ext)),
268 };
269
270 Ok(script)
271}