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 , 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:lang del propio documento.
  • Llegó la primera contribución externa: @ikashapov añadió los formatos de numeración rusos, el análisis de w:commentReference y tres correcciones en las etiquetas de listas.
  • Los datos de configuración regional se distribuyen en un único blob recortado, cargado con icu_provider_blob en lugar del compiled_data que 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. soffice se 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 soffice esperando 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 campo DATE con una máscara localizada no se entendía en absoluto.
  • La numeración escrita en palabras solo sabía inglés. cardinalText en un documento en español producía Twenty one en vez de Veintiuno.
  • 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:

FixturePáginasEntradaTiempo de conversiónPico de RSS
sample-docx-files-sample3334 KB170 ms55 MB
sample-docx-files-sample-4710 KB170 ms52 MB
sample-docx-files-sample191,3 MB55 ms42 MB
sample-docx-files-sample417114 MB420 ms159 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

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

0.5.0 es una versión centrada en la internacionalización: saltos de línea UAX #14 vía ICU4X (incluidos tailandés, lao, jemer y birmano), texto bidireccional UAX #9 con reflejo especular según la regla L4, shaping con HarfBuzz para escrituras de unión cursiva, separadores decimales que tienen en cuenta la región y máscaras de campo DATE/TIME localizadas según el w:lang del documento, y números escritos en palabras en inglés, alemán, francés y español. También corrige varios casos límite de paginación, añade síntesis de negrita y oblicua, incorpora Windows a la matriz de CI y publica un .deb para Debian y Ubuntu.
LibreOffice en modo --headless es la respuesta habitual y convierte DOCX con buena fidelidad, pero es una suite de escritorio ejecutándose en el servidor. Se serializa en torno a su directorio de perfil, así que cada worker concurrente necesita el suyo; puede colgarse con documentos poco habituales y necesita un timeout, un vigilante y un recolector de procesos huérfanos a su alrededor; consume cientos de megabytes residentes y obliga a una imagen de contenedor a juego; y su salida puede cambiar cuando cambia la versión de LibreOffice. dxpdf es un único binario en Rust con API de librería, un perfil de memoria predecible y una salida que solo cambia cuando cambia el conversor.
No. dxpdf es un binario autónomo en Rust que lee archivos DOCX directamente y genera el PDF a través de Skia. No hace falta instalar Office, ni un proceso headless de LibreOffice, ni ningún servicio externo, lo que además significa que los documentos nunca salen de la máquina que hace la conversión.
En un Apple M3 Max las fixtures versionadas se convierten en 55-170 ms, y un documento de 171 páginas y 14 MB en unos 420 ms. El tamaño del documento importa menos que cómo se resuelven las fuentes: un documento cuyas fuentes están incrustadas o ya presentes en el sistema gasta unos 4 ms en resolverlas, mientras que uno que recurre al índice de metadatos del sistema paga entre 120 y 185 ms una sola vez.
Desde 0.5.0: saltos de línea UAX #14 para todas las escrituras, incluidas las que se escriben sin espacios (tailandés, lao, jemer, birmano); texto bidireccional UAX #9 para árabe y hebreo con reflejo especular; y shaping con HarfBuzz para escrituras con formas posicionales como el árabe, el siríaco, el n'ko, el mongol y el adlam. La reordenación índica todavía no está soportada, y no hay sustitución de fuente glifo a glifo: el documento debe nombrar una fuente que cubra los caracteres que usa, tal y como los escribe Word.
Sí. Instálalo con pip install dxpdf y llama a dxpdf.convert(bytes) o a dxpdf.convert_file("input.docx", "output.pdf"). Se publican wheels para macOS, Linux y Windows en Python 3.8 y superiores, así que no necesitas una toolchain de Rust para usarlo.
Abre una issue con un DOCX que se renderice mal: el proyecto se apoya en fixtures, así que un documento que reproduzca el problema vale tanto como un parche. Para código, las entradas no soportadas de la matriz de funciones del README son la hoja de ruta, cada una con su sección de ISO 29500. Abre una issue antes de un PR grande y ejecuta cargo fmt, cargo clippy y cargo test antes de subir nada.

¿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.