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    #[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}