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