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