Automatiza la conversión de documentos en Rust con unoserver
Convierte un documento o una carpeta a PDF haciendo que Rust invoque el cliente
unoconvert de unoserver. La CLI que aparece a continuación conserva los nombres
completos de los archivos de entrada, rechaza los directorios de salida existentes y devuelve un
código de salida distinto de cero si falla alguno de los archivos seleccionados. LibreOffice
permanece en ejecución en un proceso servidor vinculado a loopback mientras Rust intenta convertir
los archivos de forma secuencial.
Instala unoserver y las dependencias de Rust
Usa Linux con los componentes Writer, Calc e Impress de LibreOffice, el puente UNO de Python
correspondiente y el soporte para venv de Python instalados. En
Debian/Ubuntu, se obtienen de los paquetes libreoffice,
python3-uno y python3-venv. Instala una cadena de herramientas de
Rust que tenga mantenimiento, con Cargo, siguiendo las
instrucciones de instalación de Rust.
Las versiones probadas fueron Rust/Cargo 1.98.1, Python 3.14.7, LibreOffice 26.8.0.3 y unoserver 3.7;
la versión del compilador es un registro de las pruebas, no una versión exacta exigida por este
ejemplo.
Crea un proyecto nuevo sin dependencias de Cargo. La tabla vacía [workspace] lo
convierte en su propia raíz de workspace,
por lo que la configuración no añade un miembro al manifiesto de un proyecto que lo contenga. El
subshell deja intacto el directorio de tu terminal y mkdir rechaza un
proyecto existente:
(
command -v cargo >/dev/null &&
command -v rustc >/dev/null &&
mkdir doc_converter_rust &&
cd doc_converter_rust &&
mkdir src &&
cat > Cargo.toml <<'TOML'
[package]
name = "doc_converter_rust"
version = "0.1.0"
edition = "2021"
[workspace]
TOML
)
Desde el directorio donde creaste el proyecto, ejecuta cd doc_converter_rust en cada terminal.
Usa ese directorio del proyecto para los comandos restantes. Instala el cliente y el servidor con
sus versiones fijadas en el venv de este proyecto:
python3 -m venv --system-site-packages .venv &&
.venv/bin/python -c 'import uno' &&
.venv/bin/python -m pip install unoserver==3.7 &&
.venv/bin/unoconvert --version
La importación de UNO debe completarse correctamente antes de que continúe la instalación. Si
falla, instala el puente para ese intérprete exacto de Python y repite este bloque; reutiliza el
venv parcial sin reemplazar los archivos de tu proyecto. Es posible que un entorno aislado
pipx predeterminado no tenga acceso al puente. La
guía de instalación de unoserver
explica esta correspondencia entre Python y LibreOffice e indica que macOS y Windows no son
compatibles; este tutorial cubre Linux.
En una terminal, ejecuta el proceso servidor con ambas interfaces vinculadas a loopback y puertos separados:
.venv/bin/python - <<'PY' &&
import socket
for port in (2003, 2002):
with socket.socket() as listener:
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
listener.bind(('127.0.0.1', port))
except OSError as error:
raise SystemExit(f'Loopback port {port} is unavailable: {error}')
PY
.venv/bin/unoserver --interface 127.0.0.1 --port 2003 \
--uno-interface 127.0.0.1 --uno-port 2002 --conversion-timeout 120
Mantén esa terminal abierta. Tras el inicio, comprueba el proceso servidor desde la segunda terminal:
.venv/bin/unoping --host 127.0.0.1 --port 2003
Deberías ver información de las versiones, incluidas unoserver 3.7 y LibreOffice. La comprobación de puertos rechaza los procesos que ya estén escuchando antes de iniciar LibreOffice. Si falla, elige un par de puertos libres y actualiza la prueba, las opciones del servidor y el puerto del cliente de Rust que aparece más abajo para que coincidan. No te conectes a un proceso existente que esté escuchando y que no hayas identificado. La comprobación libera sus sockets antes del inicio; si aparece un proceso que escucha en ese intervalo y el inicio falla, detén el proceso hijo de LibreOffice que siga en ejecución para este servidor antes de volver a intentarlo. Ambas interfaces carecen de autenticación, y el tiempo límite de conversión termina LibreOffice y cierra el servidor si una conversión se bloquea, como se describe en la documentación del cliente y del servidor. Mantén ambos puertos privados. Presiona Ctrl+C en la terminal del proceso servidor para detenerlo cuando termines.
Integra Rust con unoserver
Guarda esta CLI completa como src/main.rs. Acepta un archivo o un directorio y
requiere un directorio de salida nuevo. Cada PDF conserva el nombre completo del archivo de entrada
más .pdf, de modo que report.doc y
report.docx no pueden generar nombres coincidentes. Las rutas del sistema de
archivos se mantienen como rutas nativas en lugar de convertirse en cadenas con pérdida de
información para los argumentos del proceso.
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
}
}
}
Con un archivo DOCX real en la ruta de entrada, ejecuta:
PATH="$PWD/.venv/bin:$PATH" cargo run -- /absolute/path/example.docx new-pdfs
Deberías ver una línea Converted que indique el archivo de entrada y
new-pdfs/example.docx.pdf. Abre ese PDF y compara su texto, cantidad de páginas y diseño con el
original. La comprobación %PDF- solo verifica la cabecera; no puede demostrar
que todas las páginas u objetos incrustados se hayan conservado durante la conversión. LibreOffice
también puede detectar texto cuyo archivo se haya renombrado como .docx,
por lo que renombrar un archivo no es una prueba fiable de fallo de conversión.
Ejecuta la CLI y el servidor con la misma cuenta restringida y con acceso al mismo sistema de
archivos, como exige --host-location local. El sistema de archivos de salida debe admitir
enlaces duros; la creación del enlace final
falla si ese nombre de archivo ya existe.
Convierte carpetas completas por lotes
El mismo ejecutable procesa un lote de archivos de una carpeta sin recorrer sus subcarpetas ni iniciar un número ilimitado de tareas:
PATH="$PWD/.venv/bin:$PATH" cargo run -- /absolute/path/documents new-batch-pdfs
Ignora los subdirectorios, los enlaces simbólicos y las extensiones no admitidas, intenta convertir los archivos admitidos en orden, conserva los resultados satisfactorios y termina con un código de error si falla alguna conversión. La extensión de un archivo es solo un filtro inicial, no una prueba de que el archivo sea válido o seguro. No permitas que otros procesos modifiquen los directorios de entrada o salida durante una tarea.
Para report.doc y report.docx, deberías obtener archivos
report.doc.pdf y report.docx.pdf separados.
Si falla otro archivo seleccionado, estos PDF convertidos correctamente se conservan, el nombre
del archivo que falló aparece en stderr y el lote termina con el estado 1. Vuelve a intentarlo en un
directorio de salida nuevo después de resolver el problema de la entrada o del proceso servidor;
repetir el comando con el directorio anterior falla sin reemplazar sus PDF.
Gestiona los errores adecuadamente
- Una entrada que falta, una selección vacía o un directorio de salida existente producen un fallo visible.
- Una conversión fallida no publica su PDF parcial. Inspecciona el código de salida distinto de cero del lote aunque algunas conversiones anteriores hayan tenido éxito.
- Un tiempo límite excedido en el servidor cierra el proceso servidor. Reinícialo antes de volver a intentarlo y limita el número de reintentos.
- El cliente no es un supervisor: añade un plazo máximo para toda la tarea que termine la CLI y su proceso hijo. Terminar solo el cliente no necesariamente cancela el trabajo que ya se está ejecutando en LibreOffice.
- Los diagnósticos locales pueden incluir rutas o detalles de los documentos. Guárdalos en registros con acceso restringido en lugar de devolver los errores sin filtrar del conversor mediante una API web.
La CLI proporcionada está dirigida a un único proceso servidor. Si una aplicación pone subidas en cola, mantén el proceso servidor detrás de la autenticación y las comprobaciones de almacenamiento de esa aplicación; exponer XML-RPC permite que quienes lo invoquen eludan esas medidas. Para añadir más procesos servidores, configura perfiles de LibreOffice separados y pares de puertos XML-RPC/UNO que no se solapen. Después, dirige cada tarea explícitamente en lugar de iniciar un servidor para cada conversión.
Formatos compatibles de un vistazo
La compatibilidad real depende de los componentes y filtros de LibreOffice instalados. Empieza con esta selección conservadora y verifica documentos representativos:
| Categoría | Entradas de ejemplo | Salida de esta CLI |
|---|---|---|
| Procesamiento de texto | DOCX, DOC, ODT, RTF, TXT | |
| Hojas de cálculo | XLSX, XLS, ODS, CSV | |
| Presentaciones | PPTX, PPT, ODP |
Instala las fuentes necesarias e inspecciona la paginación, las fórmulas, los objetos incrustados y
la representación visual. Un proceso que termina correctamente y una comprobación
%PDF- de la cabecera del archivo no demuestran la fidelidad visual ni
validan una firma digital.
Para la conversión gestionada de documentos, consulta el Robot 🤖 /document/convert de Transloadit.
