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