Skip to main content

winprint_kit/
office.rs

1//! Office → PDF conversion (PowerShell + COM, requires MS Office 2010+).
2
3use 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    /// Human-readable Office application name (for failure detail text).
20    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
29/// Map a detected file type (extension string) to the Office app required to open it.
30pub 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}