dxpdf convierte archivos .docx en PDF. Sin Microsoft Office, sin LibreOffice en modo headless, sin API en la nube: un único binario en Rust que analiza el OOXML directamente y pinta el resultado a través de Skia, el motor gráfico de Google. Lo construimos porque cada alternativa que probamos nos obligaba a elegir entre fidelidad, velocidad y no enviar los documentos de nuestros clientes a un tercero; y porque la que de verdad teníamos en marcha, LibreOffice en modo headless, resultó ser una aplicación de escritorio a la que había que hacer de niñera en producción.
La versión 0.5.0 se publicó el 11 de agosto. Es la versión en la que el conversor dejó de suponer, en una docena de pequeños detalles, que el documento que le llega está escrito en inglés.
Ideas clave
- 0.5.0 es una versión centrada en la internacionalización. Saltos de línea según UAX #14 (incluidos tailandés, lao, jemer y birmano), texto bidireccional según UAX #9 con reflejo especular según la regla L4, y números y fechas de CLDR que siguen el
w:langdel propio documento. - Llegó la primera contribución externa: @ikashapov añadió los formatos de numeración rusos, el análisis de
w:commentReferencey tres correcciones en las etiquetas de listas. - Los datos de configuración regional se distribuyen en un único blob recortado, cargado con
icu_provider_bloben lugar delcompiled_dataque trae cada crate de ICU4X, porque «correcto en todas las locales» no debería significar «y ahora el binario es enorme». - Lo que decide el coste de una conversión es cómo se resuelven las fuentes, no el tamaño del documento: uno de 9 páginas y 1,3 MB se convierte en 55 ms mientras que otro de 3 páginas y 34 KB tarda 170 ms, y toda la diferencia está en sus fuentes.
- La cobertura frente a ISO 29500 es ya de 74 funciones completas, 11 parciales y 12 aún no soportadas, y esa última columna es la hoja de ruta más honesta que podemos publicar.
- Sustituyó a un pipeline con LibreOffice headless: una suite de escritorio detrás de una cola, un vigilante y un directorio de perfil por worker. Lo que eso costaba está más abajo.
- dxpdf es software libre con licencia MIT, está en crates.io y PyPI, y ahora también se distribuye como
.deb.
Gracias a nuestro primer colaborador externo
0.5.0 es la primera versión de dxpdf con una sección New Contributors, y es un buen estreno. @ikashapov sacó adelante la #118: análisis de w:commentReference, los formatos de numeración russianUpper y russianLower, y la corrección de tres defectos distintos de maquetación y numeración en las etiquetas de listas.
No es una errata arreglada de pasada. Los formatos de numeración son exactamente el tipo de función que solo encuentra quien tiene documentos que la usan: un contrato en ruso con una lista а)/б)/в) no aparece en un conjunto de fixtures reunido en inglés, y los tres defectos de etiquetado que destapó estaban en la ruta de maquetación común, así que afectaban a cualquiera con documentos de forma parecida. Gracias.
Si estás leyendo esto y dxpdf estropea tus documentos, esa es justo la contribución que más nos interesa. Al final volvemos a ello.
Lo que teníamos antes: LibreOffice en modo headless
Antes de dxpdf había un soffice --headless --convert-to pdf envuelto en una cola y un reintento, como en la mayoría de pipelines documentales. Funciona, y durante mucho tiempo fue la decisión correcta: nada más convierte DOCX con esa fidelidad al precio de un apt install. Lo que no es es un componente que puedas poner en la ruta de una petición y dejar de pensar en él:
- Es una aplicación de escritorio disfrazada de servidor. No hay API de librería ni llamada en proceso: lanzas un binario, lees un código de salida y esperas lo mejor; desde fuera, una conversión fallida y un proceso caído se parecen demasiado. Además, la primera conversión de cada proceso paga el arranque de una suite ofimática entera.
- Un proceso, un perfil.
sofficese serializa en torno a su directorio de perfil, así que cada worker concurrente necesita el suyo o compiten por los mismos ficheros de bloqueo. Escalar es gestión de procesos, no un pool de hilos. - Se cuelga. Un documento raro o mal formado puede dejar a
sofficeesperando para siempre, así que en producción acaban creciendo a su alrededor un timeout, un vigilante y un recolector de procesos huérfanos. Ese código de niñera lo termina escribiendo todo equipo que se mete en esto. - Memoria y tamaño de imagen. Cientos de megabytes residentes por instancia y una imagen de contenedor que carga con una suite ofimática completa, más las fuentes, que hay que instalar o las métricas se sustituyen en silencio y la maquetación se mueve.
- La fidelidad depende de la versión. El mismo documento convertido en otra versión de LibreOffice puede paginar distinto, con lo que subir la imagen base se convierte en un cambio en lo que recibe el cliente. Enterarte por el cliente es la peor forma de enterarte.
Nada de esto es un informe de errores contra LibreOffice: es una suite ofimática y es muy buena siéndolo. Es una afirmación sobre lo que ocurre cuando una aplicación de escritorio pasa a ser la pieza que sostiene un pipeline de servidor. dxpdf salió de querer una sola llamada de librería, un perfil de memoria predecible y una salida que solo cambia cuando la cambiamos nosotros.
El problema: un motor de maquetación que solo conocía un alfabeto
La premisa de dxpdf siempre ha sido la fidelidad. Un conversor de DOCX a PDF sirve en la medida en que los saltos de página caen donde Word los pone: una factura cuya fila de total se va a la segunda página es peor que no tener PDF. Toda la arquitectura del proyecto existe para eso: analizar el OOXML hacia un modelo inmutable, resolver la cascada de estilos, medir y colocar antes de pintar nada.
Lo que teníamos en 0.4.0 era un motor que hacía eso muy bien para texto compuesto de espacios y letras latinas. Cada suposición que había debajo era invisible hasta que se rompía:
- Las líneas se cortaban por los espacios. Eso no es un algoritmo de salto de línea, es una heurística que da la casualidad de que funciona en inglés. El tailandés, el lao, el jemer y el birmano se escriben sin espacios entre palabras: con esa regla, un párrafo entero es un único token indivisible que se sale de la página.
- El texto se maquetaba siempre de izquierda a derecha. Un párrafo en árabe o hebreo salía como un sinsentido visual: glifos correctos, orden incorrecto, corchetes sin reflejar.
- Los números y las fechas se formateaban a la americana. Una tabulación decimal se alineaba con
.cuando un documento en español o alemán quería,. Un campoDATEcon una máscara localizada no se entendía en absoluto. - La numeración escrita en palabras solo sabía inglés.
cardinalTexten un documento en español producíaTwenty oneen vez deVeintiuno. - El espaciado entre caracteres operaba sobre puntos de código. Añade 2 pt de espaciado a una cadena con un signo combinante y el signo se separa de la letra a la que pertenece.
Nada de esto es exótico. Es lo que ocurre la primera vez que un conversor escrito por angloparlantes se encuentra con un documento que no lo es.
La solución: especificaciones de verdad, no más heurísticas
El hilo conductor de 0.5.0 es que cada uno de esos puntos se sustituyó por el algoritmo real de Unicode o de OOXML, en lugar de por una conjetura mejor.
Los saltos de línea son ahora UAX #14, vía ICU4X, resueltos por párrafo y no por fragmento: una palabra partida por una frontera <w:r> a causa de un cambio de formato accidental se sigue cortando donde dice el algoritmo, no donde está cortado el XML. Las cuatro escrituras que UAX #14 delega explícitamente al «análisis contextual complejo» obtienen fronteras de palabra calculadas por una LSTM. Un token que ninguna regla permite dividir se corta en el borde del contenedor en lugar de desbordarlo, que es lo que hace Word y lo que necesita una celda de tabla estrecha.
El texto bidireccional es ahora UAX #9: niveles de incrustación resueltos por párrafo, reordenación por línea y reflejo especular según la regla L4 para que los paréntesis y corchetes miren hacia donde deben. La alineación w:jc y la sangría w:ind se resuelven respecto a la dirección base del párrafo, no respecto a «la izquierda».
Las escrituras con formas posicionales pasan por el shaping de HarfBuzz. El árabe, el siríaco, el n'ko, el mongol o el adlam necesitan unión cursiva para resultar siquiera legibles, así que un fragmento (run) cuya escritura tiene formas posicionales pasa por el HarfBuzz de Skia; todo lo demás conserva la ruta anterior por cmap, que es más barata.
Los números y las fechas siguen a w:lang. Los separadores decimales vienen de CLDR y tienen en cuenta la región: de-CH y de-DE no coinciden entre sí, y ahora dxpdf reproduce esa diferencia correctamente. Los campos DATE y TIME se evalúan con los nombres de máscara localizados de §17.16.4.2. Los números se escriben en palabras en inglés, alemán, francés y español (Veintiuno, 1.º, Vingt et un, Eins), y el resto de idiomas siguen viendo dígitos.
El espaciado y la justificación operan sobre clústeres de grafemas, según UAX #29: el espaciado de §17.3.2.35 ya nunca separa un signo combinante de su base, y la alineación distribute de §17.3.1.13 reparte el ancho sobrante entre clústeres, nunca dentro de uno.
Junto al trabajo de internacionalización, 0.5.0 corrige además los casos límite de paginación que se notan de inmediato: un salto de sección continuo se promueve a salto de página cuando la configuración de página realmente difiere, un párrafo marcado como «conservar con el siguiente» se queda al pie de su página antes de un salto explícito, y los párrafos formados solo por un salto reciben la altura de línea que les corresponde. Además, síntesis de negrita y oblicua para tipografías sin negrita real, Windows en la matriz de CI y empaquetado para Debian.
Tres decisiones que merece la pena explicar
Las unidades son tipos, no números
OOXML mide en twips, EMU, medios puntos, octavos de punto y milésimas de porcentaje, a veces tres de ellos en el mismo elemento. Lo obvio sería convertirlo todo a f64 en la frontera del parser y seguir adelante. dxpdf hace justo lo contrario: cada unidad de OOXML es su propio tipo respaldado por i64 en model::dimension, todas atraviesan el análisis y la resolución sin convertirse —así van y vuelven sin pérdida—, la maquetación trabaja exclusivamente en Pt, y el f32 a secas solo aparece en la frontera con Skia.
DOCX (ZIP) → Parse → Document Model → Resolve → Layout → Subset → Paint → PDF
Twips/Emu/HalfPoints ←──── Pt throughout ────→ Skia
La recompensa es que sumar un valor en twips a uno en medios puntos es un error de compilación en vez de un documento con un margen equivocado por un factor de diez. Esa clase de fallo es dificilísima de detectar a ojo —la salida sigue pareciendo un documento plausible, solo que sutilmente mal— y desaparece por completo si el compilador no deja mezclar las unidades. Los tipos geométricos son genéricos sobre la unidad por la misma razón, con sus equivalentes especializados en Pt en render::geometry, de modo que la capa del modelo no depende de Skia en absoluto.
Los datos de locale son un blob recortado, no los datos compilados por defecto
Los crates de ICU4X traen una feature compiled_data que incrusta el conjunto completo de CLDR en el binario. Es el camino fácil, y es descomunal en tamaño: te llevas todas las locales, todos los calendarios y todas las monedas, las mencione o no el documento.
En su lugar, dxpdf construye un único blob de datos recortado a las locales que realmente soporta y lo carga mediante icu_provider_blob. Más infraestructura de compilación, un artefacto más que mantener sincronizado, y un binario que un usuario de CLI querrá instalar de verdad. El mismo instinto aparece al lado, en la decisión sobre el shaping: unicode-joining-type se usa como predicado, es decir, como la pieza cuyo único trabajo es mantener a HarfBuzz lejos del texto latino, para que la ruta cara se ejecute en las escrituras que la necesitan y en ninguna otra. Ambos son el mismo trato: pagar por la corrección donde hace falta, no de forma uniforme en todas partes.
La maquetación ganó un ámbito especulativo
La pregunta de §17.6.22 —si un salto de sección continuo se queda en la página actual— no se puede responder avanzando hacia delante: la respuesta depende de lo que venga después del salto. Así que la maquetación tiene que probar una colocación, mirar hacia delante y deshacer el intento si resulta ser la equivocada.
Eso es imposible si tu estado de maquetación es un &mut que vas mutando por toda la cadena de llamadas. La #111 añadió un ámbito especulativo de BuildState: una región de trabajo de maquetación que se puede confirmar o descartar en bloque. Es una buena ilustración de un patrón que se repite en este proyecto: la especificación no solo te dice qué implementar, también te dice qué forma tiene que tener tu arquitectura. La maquetación de Word incorpora anticipación, de modo que un conversor que quiera los saltos de página de Word necesita un sitio donde dejar un intento descartado.
Las cifras
Medido en un Apple M3 Max con hyperfine (30 ejecuciones, 5 de calentamiento) en la v0.5.0, contra fixtures versionadas en el repositorio para que los números sean reproducibles:
| Fixture | Páginas | Entrada | Tiempo de conversión | Pico de RSS |
|---|---|---|---|---|
sample-docx-files-sample3 | 3 | 34 KB | 170 ms | 55 MB |
sample-docx-files-sample-4 | 7 | 10 KB | 170 ms | 52 MB |
sample-docx-files-sample1 | 9 | 1,3 MB | 55 ms | 42 MB |
sample-docx-files-sample4 | 171 | 14 MB | 420 ms | 159 MB |
La fila interesante es la tercera. Un documento de 9 páginas que lleva cuarenta veces más entrada se convierte en un tercio del tiempo que necesita el de 3 páginas, porque lo que decide el coste de una conversión es cómo se resuelven las fuentes, no el tamaño del documento. El registro de fuentes se construye por niveles y de forma perezosa: un documento cuyas fuentes están incrustadas o ya presentes en el sistema nunca llega al nivel caro y gasta allí unos 4 ms, mientras que uno que debe recurrir al índice de metadatos del sistema y emparejar por nombres PostScript y de estilo paga entre 120 y 185 ms, una sola vez. En la fixture de 3 páginas esa búsqueda cuesta aproximadamente cinco veces lo que suman el análisis, la maquetación, el subsetting y el pintado juntos.
Para quien esté dimensionando una carga por lotes, eso replantea la pregunta por completo. No es cómo de grandes son los documentos, sino si nombran fuentes que el sistema ya tiene. Los documentos escritos con Word normalmente sí.
Cobertura y adopción, en el momento de escribir esto:
- 74 funciones de OOXML implementadas por completo, 11 parciales y 12 aún no soportadas, validadas contra ISO 29500. La matriz completa está en el README, huecos incluidos.
- Unas 6000 descargas en crates.io repartidas en 38 versiones publicadas, además de wheels en PyPI para macOS, Linux y Windows en Python 3.8+.
- 29 estrellas, 6 forks, licencia MIT, cinco meses de vida.
- En producción en nerdy.pro y formtastic.de.
Y la otra cara honesta del balance: sin reordenación índica, sin sustitución de fuente glifo a glifo, sin partición automática de palabras, sin control de cambios ni comentarios, sin SmartArt ni gráficos, y el ajuste de imagen en modos tight y through aproximado por el rectángulo contenedor en lugar de por un polígono. Todo ello aparece, con su ❌ al lado, en la misma tabla que los aciertos.
Instalación
cargo install dxpdf # CLI
pip install dxpdf # Python
curl -LO https://github.com/nerdy-pro/dxpdf/releases/download/v0.5.0/dxpdf_0.5.0-1_amd64.deb
sudo apt install ./dxpdf_0.5.0-1_amd64.deb
- Código e issues: github.com/nerdy-pro/dxpdf
- Notas de la versión: v0.5.0
- Crate: crates.io/crates/dxpdf · Documentación de la API: docs.rs/dxpdf
- Paquete de Python: pypi.org/project/dxpdf
- Página del proyecto: dxpdf en nerdy.pro
- Lectura de fondo: cómo generamos PDF en Rust con Skia, los cimientos sobre los que se pinta todo lo anterior
Las contribuciones se agradecen de verdad
dxpdf está escrito en Rust y el ciclo de contribución es corto. Dos cosas valen más de lo que parecen:
Un DOCX que se renderiza mal vale tanto como un parche. El proyecto se apoya en fixtures: un documento que reproduce un defecto, versionado junto a su corrección, así es como quedó fijada cada función de esa tabla de cobertura. Si dxpdf estropea un documento tuyo, abre una issue con el archivo, o con la versión más pequeña de él que puedas compartir.
La columna ❌ es la hoja de ruta. Partición automática de palabras, sustitución de fuente glifo a glifo, tabulaciones reflejadas bajo w:bidi, chineseCounting y el resto de formatos de conteo, SmartArt: cada una es una tarea bien acotada con su sección de la especificación asociada. El primer PR de @ikashapov empezó exactamente ahí.
Abre una issue antes de un PR grande y, antes de subir nada, ejecuta lo mismo que ejecuta CI:
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test --all
Las convenciones del proyecto están en AGENTS.md.
Preguntas frecuentes
¿Conviertes documentos a escala?
dxpdf es software libre con licencia MIT: úsalo, haz un fork o cuéntanos dónde se queda corto con tus documentos. Si necesitas procesamiento de documentos integrado en un producto y no pegado por fuera, escríbenos: este conversor existe porque nos hacía falta una y otra vez en proyectos de cliente, y preferimos mantenerlo en abierto a reconstruirlo en privado cada vez.
Ilya Nixan es fundador y lead developer en Nerdy Production, una agencia Flutter-first que además construye y mantiene las herramientas de infraestructura —como dxpdf y Orosu— sobre las que corre su propia entrega de proyectos.

