Automate document conversion in Rust with unoserver
Rust can orchestrate LibreOffice document conversion through unoserver and its unoconvert
client. This guide uses the documented CLI instead of an additional Rust wrapper. A single
supervised LibreOffice worker handles requests sequentially, while Rust manages input selection,
output ownership, and failures.
Install unoserver and Rust dependencies
On Ubuntu/Debian, install LibreOffice components, the distribution's Python UNO bridge, and a
virtual-environment package. The Python interpreter used for unoserver must be able to import
uno; a default isolated pipx environment may not see it.
sudo apt-get update
sudo apt-get install libreoffice python3-uno python3-venv
python3 -m venv --system-site-packages .venv
. .venv/bin/activate
python -c 'import uno'
pip install unoserver==3.7
unoconvert --version
The examples target Linux and macOS, not native Windows. On macOS, follow the
unoserver installation guide to select a Python
interpreter compatible with LibreOffice. Verify import uno with that exact interpreter.
In one terminal, run the worker with both interfaces bound to loopback and separate ports:
unoserver --interface 127.0.0.1 --port 2003 \
--uno-interface 127.0.0.1 --uno-port 2002 --conversion-timeout 120
Wait for successful startup in its log before running a conversion. The conversion timeout terminates LibreOffice and exits the server if a conversion hangs, so a supervisor must restart the worker before later jobs. Do not expose either port publicly; the service is not an authenticated upload API.
Install Rust through your usual supported toolchain and create the project in another terminal:
cargo new doc_converter_rust
cd doc_converter_rust
Ensure the virtual environment's unoconvert is also on this terminal's PATH. No Cargo
dependencies are needed. Test with a real DOCX, ODT, or other supported document; a text file renamed
with a .docx extension is not a valid DOCX fixture.
Integrate Rust with unoserver
Replace src/main.rs with this complete CLI. It accepts one file or a directory and requires a new
output directory. Each PDF retains the full input filename plus .pdf, so report.doc and
report.docx cannot collide. Filesystem paths remain native paths rather than being converted to
lossy strings for process arguments.
use std::error::Error;
use std::ffi::OsStr;
use std::fs::{self, DirBuilder, File};
use std::io::{self, Read};
use std::os::unix::fs::DirBuilderExt;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
type Result<T> = std::result::Result<T, Box<dyn Error>>;
fn supported(path: &Path) -> bool {
let extension = path.extension().and_then(OsStr::to_str).unwrap_or("").to_ascii_lowercase();
matches!(extension.as_str(), "doc" | "docx" | "odt" | "rtf" | "txt" |
"ppt" | "pptx" | "odp" | "xls" | "xlsx" | "ods" | "csv")
}
fn convert_one(input: &Path, output: &Path) -> Result<()> {
let candidate = output.join(".conversion.pdf");
let conversion = (|| -> Result<()> {
let status = Command::new("unoconvert")
.args(["--host", "127.0.0.1", "--port", "2003", "--host-location", "local"])
.arg(input).arg(&candidate).status()?;
if !status.success() {
return Err(io::Error::other("unoconvert failed").into());
}
let mut header = [0; 5];
File::open(&candidate)?.read_exact(&mut header)?;
if &header != b"%PDF-" {
return Err(io::Error::other("conversion did not produce a PDF").into());
}
let mut filename = input.file_name().ok_or_else(|| io::Error::other("missing filename"))?.to_os_string();
filename.push(".pdf");
// Same-filesystem publication must fail rather than overwrite a destination.
fs::hard_link(&candidate, output.join(filename))?;
Ok(())
})();
if candidate.exists() {
fs::remove_file(&candidate)?;
}
conversion
}
fn run() -> Result<()> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() != 2 {
return Err(io::Error::other("Usage: doc_converter_rust <input-file-or-directory> <new-output-directory>").into());
}
let source = PathBuf::from(&args[0]);
let metadata = fs::symlink_metadata(&source)?;
let mut inputs = Vec::new();
if metadata.is_file() && supported(&source) {
inputs.push(source.canonicalize()?);
} else if metadata.is_dir() {
for entry in fs::read_dir(&source)? {
let entry = entry?;
if entry.file_type()?.is_file() && supported(&entry.path()) {
inputs.push(entry.path().canonicalize()?);
}
}
} else {
return Err(io::Error::other("use a supported regular file or directory, not a symlink").into());
}
inputs.sort();
if inputs.is_empty() {
return Err(io::Error::other("no supported documents found").into());
}
let output = PathBuf::from(&args[1]);
DirBuilder::new().mode(0o700).create(&output)?;
let output = output.canonicalize()?;
let mut failures = 0;
for input in inputs {
match convert_one(&input, &output) {
Ok(()) => println!("Converted {}", input.display()),
Err(_) => {
failures += 1;
eprintln!("Conversion failed for {}", input.display());
}
}
}
if failures > 0 {
return Err(io::Error::other(format!("{failures} conversion(s) failed")).into());
}
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("Document conversion failed: {error}");
ExitCode::FAILURE
}
}
}
Run cargo run -- /absolute/path/example.docx new-pdfs. The result is
new-pdfs/example.docx.pdf. The output filesystem must support hard links. Run the CLI and server
as the same restricted worker account with access to the same filesystem, as required by
--host-location local.
Batch-convert whole folders
The same executable handles a non-recursive folder batch without launching unbounded tasks:
cargo run -- /absolute/path/documents new-batch-pdfs
It ignores subdirectories, symlinks, and unsupported extensions, attempts supported files in sorted order, preserves successful results, and exits unsuccessfully if any conversion fails. A file extension is only an initial filter, not proof that the file is valid or safe. Do not let other processes modify the input or output directories during a job.
Handle errors gracefully
- A missing input, empty selection, or existing output directory is a visible failure.
- A failed conversion does not publish its partial PDF. Inspect the nonzero batch status even if some earlier conversions succeeded.
- A server-side timeout exits the worker. Restart it before retrying, and keep retry counts bounded.
- The client is not a supervisor: add a whole-job deadline that terminates the CLI and its child process. Killing only the client does not necessarily cancel work already running in LibreOffice.
- Local diagnostics can include paths or document details. Keep them in restricted logs rather than returning raw converter errors through a web API.
Scale with multiple unoserver instances
Give each worker its own LibreOffice profile, non-overlapping port pair, resource limits, and job
queue. For example, use XML-RPC/UNO pairs 2003/2002, 2013/2012, and 2023/2022; do not reuse a
port for a different worker's interface. The supplied CLI deliberately targets only the first
worker. Add explicit worker configuration when scaling instead of starting servers inside every
conversion request.
Supported formats at a glance
Actual support depends on the installed LibreOffice components and filters. Start with this conservative selection and verify representative documents:
| Category | Example inputs | Output in this CLI |
|---|---|---|
| Word processing | DOCX, DOC, ODT, RTF, TXT | |
| Spreadsheets | XLSX, XLS, ODS, CSV | |
| Presentations | PPTX, PPT, ODP |
Install the required fonts and inspect pagination, formulas, embedded objects, and rendering. A
successful process and a %PDF- file-header check do not establish visual fidelity or validate a
digital signature.
Real-world example: convert uploads in a web API
Use an authenticated application layer to accept bounded uploads, authorize job ownership, and queue conversion work. Keep LibreOffice in isolated workers without application credentials or unrestricted network access. Return a job identifier and sanitized status; publish a download only after a completed result passes your checks.
The CLI is the conversion component, not a ready-to-deploy upload endpoint. Keep request validation, storage ownership, rate limits, malware policy, cancellation, and retention explicit in your application rather than exposing the raw XML-RPC server.
Wrap-up
Rust's standard library can run the existing unoconvert client while keeping batch control and
output ownership straightforward. Verify the UNO environment once, supervise the worker, and test
real output before adding concurrency.
For managed document conversion, see Transloadit's 🤖 /document/convert Robot.
