Nightly Build / build (push) Canceled after 10m54s
The file viewer converts word-processor documents to PDF server-side via LibreOffice (skald_core::docx::DocxConverter), mirroring the LaTeX pipeline but content-hash cached: the format is self-contained, so there is no dependency graph and the file watcher needs no expansion. Container-only documents are shuttled out and converted on the host. With no LibreOffice installed the viewer says so and falls back to download-only. Downloads still save the original document, not the preview PDF.
434 lines
17 KiB
Rust
434 lines
17 KiB
Rust
//! `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 `<tmp>/skald-docx/`:
|
|
//!
|
|
//! | Artefact | Key | Purpose |
|
|
//! |----------------------|----------------------------|-------------------|
|
|
//! | `<content-hash>.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<u8>,
|
|
/// `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<std::io::Error> 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<ConvertedPdf, ConvertError> {
|
|
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<ConvertedPdf, ConvertError> {
|
|
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<ConvertedPdf> {
|
|
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<Vec<u8>, 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<Vec<u8>, 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<PathBuf> {
|
|
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<PathBuf> {
|
|
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)));
|
|
}
|
|
}
|