//! `DocxConverter` — converts word-processor documents (`.docx`, `.doc`, //! `.odt`, `.rtf`) to PDF using LibreOffice in headless mode //! (`soffice --convert-to pdf`). //! //! Used by the file viewer (`GET /api/file?…&compile-docx=true`) to render //! word documents as PDFs on demand — the word-family twin of //! [`crate::latex::LatexCompiler`]. //! //! ## Caching (content-addressed) //! //! Unlike a `.tex` source, a word document is **self-contained**: images, //! styles and fonts travel inside the file itself, so there is no dependency //! graph to track and the `.fls`-sidecar machinery of the LaTeX cache would //! buy nothing. The cache key is a short SHA-256 of the document bytes: any //! edit changes the hash and invalidates naturally, and two paths holding the //! same document share one cached PDF. //! //! One artefact lives under `/skald-docx/`: //! //! | Artefact | Key | Purpose | //! |----------------------|----------------------------|-------------------| //! | `.pdf` | SHA-256 of the file bytes | The converted PDF | //! //! ## Container-shuttled inputs //! //! [`DocxConverter::convert_bytes`] exists for documents that live **only //! inside a user's container** (`/tmp/…`): the caller pulls the bytes out //! (`container::exec_fs::read`) and the converter works on a host-side //! scratch copy. This is correct precisely because the format is //! self-contained — a bare copy loses nothing. (LaTeX deliberately does not //! get this treatment: a shuttled `.tex` would silently lose its relative //! `\input` / `\includegraphics` dependencies.) //! //! ## LibreOffice quirks this lives with //! //! - `soffice` locks its user-profile directory, so concurrent conversions — //! or a stale lock left by a killed run — make later invocations fail. //! Every conversion therefore gets a **private profile** //! (`-env:UserInstallation`) inside its per-run scratch directory, which is //! removed afterwards. //! - A failed conversion does not always exit non-zero: a missing output //! file is treated as a failure too, with the captured output as detail. //! - The scratch copy's **name** is how soffice picks its import filter, so //! the shuttled input keeps the caller's extension (`input.docx`, //! `input.odt`, …). //! //! ## Failure modes //! - `ToolMissing` — no LibreOffice on the host (neither `soffice` / //! `libreoffice` on PATH nor the macOS app bundle). //! - `Timeout` — conversion exceeded [`CONVERT_TIMEOUT_SECS`]. //! - `Failed { output }` — non-zero exit or missing output file; carries the //! captured stdout/stderr so the viewer can surface it. //! - `Io` — underlying I/O error (reading the source, writing the cache…). use std::path::{Path, PathBuf}; use std::time::Duration; use sha2::{Digest, Sha256}; use tokio::process::Command; /// Hard ceiling for a single conversion. A cold `soffice` start with a fresh /// profile takes a few seconds; large documents add a few more — 60 s leaves /// generous headroom while still bounding a hung run. const CONVERT_TIMEOUT_SECS: u64 = 60; /// Subdirectory of the OS temp dir holding cached PDFs and per-run scratch /// directories. const CACHE_DIR_NAME: &str = "skald-docx"; /// The word-processor extensions this converter accepts — the single source /// of truth the HTTP layer (`api/files.rs::is_word_doc`) shares, so the /// query flag and the converter can never disagree on the family. pub const WORD_EXTS: &[&str] = &["docx", "doc", "odt", "rtf"]; /// A successfully converted PDF. pub struct ConvertedPdf { pub bytes: Vec, /// `true` when served from cache without invoking `soffice`. Informational /// only; kept on the struct so the API stays stable (mirrors /// `latex::CompiledPdf`). #[allow(dead_code)] pub from_cache: bool, } /// Why a conversion request did not yield a PDF. #[derive(Debug)] pub enum ConvertError { /// No LibreOffice binary is reachable on the host. ToolMissing, /// `soffice` ran but failed (non-zero exit, or no output file). Carries /// the captured process output. Failed { output: String }, /// Conversion did not finish within [`CONVERT_TIMEOUT_SECS`]. Timeout, /// Underlying I/O error (reading the source, writing the cache, etc.). Io(std::io::Error), } impl std::fmt::Display for ConvertError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::ToolMissing => write!(f, "LibreOffice is not available on the server"), Self::Failed { output } => write!(f, "conversion failed:\n{output}"), Self::Timeout => write!(f, "conversion aborted (timeout {CONVERT_TIMEOUT_SECS}s)"), Self::Io(e) => write!(f, "I/O error: {e}"), } } } impl std::error::Error for ConvertError {} impl From for ConvertError { fn from(e: std::io::Error) -> Self { Self::Io(e) } } /// Stateless-ish facade around `soffice`. Owns only the cache root path; safe /// to share via `Arc` (constructed once and stored on `Skald`). #[derive(Clone)] pub struct DocxConverter { cache_dir: PathBuf, } impl DocxConverter { pub fn new() -> Self { Self { cache_dir: std::env::temp_dir().join(CACHE_DIR_NAME) } } /// Convert a word document at `path` (a host file) to PDF, serving from /// the content-addressed cache when possible. pub async fn convert_path(&self, path: &Path) -> Result { let bytes = tokio::fs::read(path).await?; let key = content_hash(&bytes); if let Some(hit) = self.cached(&key).await { return Ok(hit); } let scratch = self.cache_dir.join(format!("run-{}", unique_suffix())); let pdf_bytes = self.run_soffice(&scratch, path).await?; self.store(&key, &pdf_bytes).await; tracing::info!(file = ?path, "word document converted (cache miss)"); Ok(ConvertedPdf { bytes: pdf_bytes, from_cache: false }) } /// Convert a word document that exists only as bytes — a file shuttled /// out of a user's container (see the module docs). `ext` (the caller's /// file extension) selects the import filter through the scratch copy's /// file name. pub async fn convert_bytes(&self, bytes: &[u8], ext: &str) -> Result { let key = content_hash(bytes); if let Some(hit) = self.cached(&key).await { return Ok(hit); } // Probe before touching the disk: with no converter installed the // request fails without leaving a scratch copy behind. let soffice = find_soffice().await.ok_or(ConvertError::ToolMissing)?; let scratch = self.cache_dir.join(format!("run-{}", unique_suffix())); tokio::fs::create_dir_all(&scratch).await?; let input = scratch.join(format!("input.{}", sanitize_ext(ext))); if let Err(e) = tokio::fs::write(&input, bytes).await { let _ = cleanup_dir(&scratch).await; return Err(ConvertError::Io(e)); } let pdf_bytes = self.run_soffice_with(&soffice, &scratch, &input).await?; self.store(&key, &pdf_bytes).await; tracing::info!(ext, "word document converted from shuttled bytes (cache miss)"); Ok(ConvertedPdf { bytes: pdf_bytes, from_cache: false }) } /// Look up a cached PDF by content key. async fn cached(&self, key: &str) -> Option { let path = self.cache_dir.join(format!("{key}.pdf")); match tokio::fs::read(&path).await { Ok(bytes) => { tracing::debug!(cached_pdf = ?path, "word-doc cache hit"); Some(ConvertedPdf { bytes, from_cache: true }) } Err(_) => None, } } /// Persist a converted PDF under its content key. A write failure is /// non-fatal: the next request simply converts again. async fn store(&self, key: &str, bytes: &[u8]) { let path = self.cache_dir.join(format!("{key}.pdf")); if let Err(e) = tokio::fs::write(&path, bytes).await { tracing::warn!(cached_pdf = ?path, error = %e, "word-doc cache write failed"); } } /// [`run_soffice_with`] with the binary probed first. Used by the /// host-path entry point, which has nothing to prepare. async fn run_soffice(&self, scratch: &Path, input: &Path) -> Result, ConvertError> { let soffice = find_soffice().await.ok_or(ConvertError::ToolMissing)?; self.run_soffice_with(&soffice, scratch, input).await } /// Run one conversion of `input` with output to `scratch` (a per-run /// unique directory, removed before returning regardless of outcome) and /// return the produced PDF bytes. /// /// `soffice` gets a **private user profile** inside the scratch dir: /// the profile is locked while in use, so a shared one would make /// concurrent conversions fail — and a stale lock from a killed run /// would make every later one fail. async fn run_soffice_with( &self, soffice: &Path, scratch: &Path, input: &Path, ) -> Result, ConvertError> { tokio::fs::create_dir_all(scratch).await?; let profile = scratch.join("profile"); let mut cmd = Command::new(soffice); cmd.args(["--headless", "--norestore", "--nolockcheck", "--nologo"]); // `profile` is always absolute (cache_dir lives under temp_dir), so // `file://` + path yields a valid `file:///…` URL on unix hosts. cmd.arg(format!("-env:UserInstallation=file://{}", profile.display())); cmd.args(["--convert-to", "pdf", "--outdir"]); cmd.arg(scratch); cmd.arg(input); cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); // If our future is dropped (e.g. on shutdown) ensure the process dies. cmd.kill_on_drop(true); let output = match tokio::time::timeout( Duration::from_secs(CONVERT_TIMEOUT_SECS), cmd.output(), ).await { Ok(Ok(o)) => o, Ok(Err(e)) => { let _ = cleanup_dir(scratch).await; return Err(ConvertError::Io(e)); } Err(_) => { // Timeout: the future is dropped here; `kill_on_drop` // terminates `soffice`. let _ = cleanup_dir(scratch).await; return Err(ConvertError::Timeout); } }; let stem = input .file_stem() .and_then(|s| s.to_str()) .unwrap_or("output") .to_string(); let pdf_path = scratch.join(format!("{stem}.pdf")); // soffice can exit 0 without producing anything (unreadable input, // unknown filter): the output file is the real success signal. let pdf_bytes = match tokio::fs::read(&pdf_path).await { Ok(b) => b, Err(_) if !output.status.success() => { let _ = cleanup_dir(scratch).await; return Err(ConvertError::Failed { output: process_output(&output) }); } Err(e) => { let _ = cleanup_dir(scratch).await; return Err(ConvertError::Failed { output: format!( "soffice exited successfully but produced no PDF ({e})\n{}", process_output(&output) ), }); } }; let _ = cleanup_dir(scratch).await; Ok(pdf_bytes) } } impl Default for DocxConverter { fn default() -> Self { Self::new() } } // ── Helpers ───────────────────────────────────────────────────────────────── // // `content_hash` / `unique_suffix` / `find_on_path` / `cleanup_dir` mirror the // private helpers of the same names in `latex/compiler.rs`. Kept as local // copies so neither module reaches into the other; if a third converter ever // appears, extraction into a shared module becomes the obvious move. /// First 5 bytes (10 hex chars) of SHA-256 — enough to avoid collisions in /// practice while keeping cache filenames short. fn content_hash(bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(bytes); let digest = hasher.finalize(); digest.iter().take(5).map(|b| format!("{b:02x}")).collect() } /// Per-run unique suffix (PID + nanosecond timestamp) to namespace the /// scratch directory and avoid races between concurrent conversions. fn unique_suffix() -> String { use std::time::{SystemTime, UNIX_EPOCH}; let pid = std::process::id(); let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); format!("{pid}-{nanos:x}") } /// Locate a LibreOffice binary: `soffice` / `libreoffice` on PATH, then the /// standard macOS app-bundle location (an installed LibreOffice that was /// never linked onto PATH). async fn find_soffice() -> Option { for name in ["soffice", "libreoffice"] { if let Some(p) = find_on_path(name).await { return Some(p); } } let app_bundle = PathBuf::from("/Applications/LibreOffice.app/Contents/MacOS/soffice"); if tokio::fs::metadata(&app_bundle).await.map(|m| m.is_file()).unwrap_or(false) { return Some(app_bundle); } None } /// Return the absolute path of `bin` if it is found on `PATH` and is a regular /// file. We avoid pulling in the `which` crate for a single lookup. async fn find_on_path(bin: &str) -> Option { let path_var = std::env::var_os("PATH")?; for dir in std::env::split_paths(&path_var) { let candidate = dir.join(bin); if tokio::fs::metadata(&candidate).await .map(|m| m.is_file() || m.file_type().is_symlink()) .unwrap_or(false) { return Some(candidate); } } None } /// The scratch copy's extension drives soffice's import-filter choice, so it /// must survive the trip. Anything outside the known word family (or weird /// bytes) becomes `docx` — which is also what content-sniffing would guess. fn sanitize_ext(ext: &str) -> String { let e = ext.to_ascii_lowercase(); if WORD_EXTS.contains(&e.as_str()) { e } else { "docx".to_string() } } /// Flatten a process's captured stdout+stderr into one displayable string, /// capped so a noisy run cannot bloat the HTTP error body. fn process_output(output: &std::process::Output) -> String { let mut text = String::new(); text.push_str(&String::from_utf8_lossy(&output.stdout)); if !text.is_empty() && !text.ends_with('\n') { text.push('\n'); } text.push_str(&String::from_utf8_lossy(&output.stderr)); let text = text.trim(); if text.is_empty() { return "(no output from soffice)".to_string(); } text.chars().take(4000).collect() } /// Recursively remove a scratch directory. Errors are logged and swallowed: /// leftover dirs only consume a little disk under the OS temp folder. async fn cleanup_dir(dir: &Path) -> std::io::Result<()> { if tokio::fs::try_exists(dir).await.unwrap_or(false) { tokio::fs::remove_dir_all(dir).await?; } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn hash_is_10_lowercase_hex_chars() { let h = content_hash(b"hello world"); assert_eq!(h.len(), 10); assert!(h.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); } #[test] fn hash_is_deterministic() { assert_eq!(content_hash(b"abc"), content_hash(b"abc")); assert_ne!(content_hash(b"abc"), content_hash(b"abd")); } #[test] fn sanitize_ext_keeps_the_word_family() { for ext in WORD_EXTS { assert_eq!(&sanitize_ext(ext), ext); } assert_eq!(sanitize_ext("DOCX"), "docx"); } #[test] fn sanitize_ext_defaults_unknowns_to_docx() { assert_eq!(sanitize_ext("pptx"), "docx"); assert_eq!(sanitize_ext("../../etc/passwd"), "docx"); assert_eq!(sanitize_ext(""), "docx"); } #[tokio::test] async fn cache_round_trip() { let dir = std::env::temp_dir().join(format!("skald-docx-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let converter = DocxConverter { cache_dir: dir.clone() }; assert!(converter.cached("deadbeef00").await.is_none()); converter.store("deadbeef00", b"%PDF-fake").await; let hit = converter.cached("deadbeef00").await.unwrap(); assert_eq!(hit.bytes, b"%PDF-fake"); assert!(hit.from_cache); let _ = std::fs::remove_dir_all(&dir); } /// With no LibreOffice on the host the converter must report ToolMissing — /// on a box *with* LibreOffice this test is skipped rather than failed, /// since it would otherwise run a real conversion. #[tokio::test] async fn missing_tool_reports_tool_missing() { if find_soffice().await.is_some() { eprintln!("LibreOffice present — skipping ToolMissing test"); return; } let dir = std::env::temp_dir().join(format!("skald-docx-test-missing-{}", std::process::id())); let converter = DocxConverter { cache_dir: dir }; let result = converter.convert_bytes(b"not a real docx", "docx").await; assert!(matches!(result, Err(ConvertError::ToolMissing))); } }