feat(viewer): preview word documents (.docx/.doc/.odt/.rtf) as PDF
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.
This commit is contained in:
Daniele
2026-09-08 16:30:13 +01:00
parent 4ea932ef54
commit 027d815b66
17 changed files with 657 additions and 17 deletions
+433
View File
@@ -0,0 +1,433 @@
//! `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)));
}
}
+1
View File
@@ -22,6 +22,7 @@ pub mod crypto;
pub mod elicitation;
pub mod cron;
pub mod db;
pub mod docx;
pub mod events;
pub mod git_versions;
pub mod image_generate;
+2
View File
@@ -29,6 +29,7 @@ use crate::elicitation::ElicitationManager;
use crate::image_generate::ImageGeneratorManager;
use crate::inbox::Inbox;
use crate::git_versions::GitVersions;
use crate::docx::DocxConverter;
use crate::latex::LatexCompiler;
use crate::llm::LlmManager;
use crate::location::LocationManager;
@@ -406,6 +407,7 @@ impl Skald {
// Infra
pub fn latex_compiler(&self) -> &LatexCompiler { &self.infra.latex_compiler }
pub fn docx_converter(&self) -> &DocxConverter { &self.infra.docx_converter }
pub fn git_versions(&self) -> &GitVersions { &self.infra.git_versions }
pub fn location_manager(&self) -> &Arc<LocationManager> { &self.infra.location_manager }
pub fn remote(&self) -> &Arc<RwLock<Option<Arc<dyn RemoteAccess>>>> { &self.infra.remote }
+3
View File
@@ -25,6 +25,7 @@ use crate::elicitation::ElicitationManager;
use crate::image_generate::ImageGeneratorManager;
use crate::inbox::Inbox;
use crate::git_versions::GitVersions;
use crate::docx::DocxConverter;
use crate::latex::LatexCompiler;
use crate::llm::LlmManager;
use crate::location::LocationManager;
@@ -440,6 +441,7 @@ impl Conversation {
pub(super) struct Infra {
pub(super) latex_compiler: LatexCompiler,
pub(super) docx_converter: DocxConverter,
pub(super) git_versions: GitVersions,
pub(super) location_manager: Arc<LocationManager>,
pub(super) remote: Arc<RwLock<Option<Arc<dyn RemoteAccess>>>>,
@@ -449,6 +451,7 @@ impl Infra {
pub(super) fn build() -> Self {
Infra {
latex_compiler: LatexCompiler::new(),
docx_converter: DocxConverter::new(),
git_versions: GitVersions::new(),
location_manager: Arc::new(LocationManager::new()),
remote: Arc::new(RwLock::new(None)),
+8 -4
View File
@@ -28,7 +28,7 @@ use crate::tools::tool_names::SHOW_FILE_TO_USER;
/// It then emits a `ServerEvent::OpenFile` carrying the **canonical agent path**, so
/// the file-viewer page fetches the same file back through `/api/file`. The
/// frontend renders every kind in the viewer (HTML live in an origin-isolated
/// iframe; LaTeX compiled to PDF server-side).
/// iframe; LaTeX compiled and word documents converted to PDF server-side).
///
/// `session_id` is the conversation this instance belongs to: clients filter
/// events per conversation, so an untagged `OpenFile` would reach nobody.
@@ -46,8 +46,10 @@ pub fn make_tool(
"name": SHOW_FILE_TO_USER,
"description": "Show a file to the user by opening it in their interface. \
Supports Markdown, source code, plain text, raster images \
(PNG/JPG/GIF/WebP/…), SVG, PDF, and LaTeX (.tex — compiled \
to PDF automatically on the server). HTML files open in a \
(PNG/JPG/GIF/WebP/…), SVG, PDF, LaTeX (.tex — compiled \
to PDF automatically on the server), and word-processor \
documents (.docx/.doc/.odt/.rtf — converted to PDF \
automatically on the server). HTML files open in a \
new browser tab. Use this to surface a file you created or \
found so the user can look at it directly. One file per call. \
The file must already exist on disk — or as a memory note \
@@ -58,7 +60,9 @@ pub fn make_tool(
whenever any of its dependencies (\\input fragments, .sty/.cls, \
images) change. A raw `.pdf` is served statically — never \
recompiled and its dependencies are not watched — so the user \
would keep seeing a stale render.",
would keep seeing a stale render. The same rule applies to \
word documents: pass the original `.docx`/`.odt`/…, never a \
PDF exported from it.",
"parameters": {
"type": "object",
"properties": {