Importación de DOCX: documentos de Word de ida y vuelta por un editor con IA
Hace dos semanas lanzamos la reescritura de la exportación a DOCX/PDF: un documento editado en AgentDoc (también: agent doc, agentdocs, docedit) ahora sale del editor como un archivo de Word con sus fuentes, colores, diseño de página y encabezados/pies de página intactos. Eso resolvía exactamente la mitad del flujo de trabajo realmente útil. La otra mitad —llevar un documento de Word hacia el editor sin perder estructura— es lo que aterrizó esta semana.
Esta entrada es la compañera de la reescritura de la exportación: el mismo modelo de datos, la dirección opuesta, la misma insistencia en no perder nada que el usuario pueda ver en pantalla.
El contrato
Un usuario sube un contract_v3.docx que ha estado editando en Word. Tras la
importación, abrir el mismo documento en el editor debería mostrar:
- Encabezados, párrafos, listas, tablas: en el nivel correcto, en el orden correcto.
- Formato en línea: negrita, cursiva, subrayado, tachado, subíndice/superíndice.
- Colores y resaltados: los colores reales de la paleta del editor, no el predeterminado que Word haya sustituido.
- Fuentes y tamaños: asignados a los doce tokens del editor (Inter, Playfair Display, Roboto Mono, etc.).
- Alineación, sangría, interlineado.
- Hipervínculos: en los que se puede hacer clic, con sus URL originales.
- Saltos de página donde el autor los puso.
- El encabezado y el pie de página de la página, con su propio formato conservado.
Exportado de nuevo a DOCX, el mismo archivo debería ser comparable byte a byte en estructura (no idéntico bit a bit —Word escribe mucho XML incidental— pero visualmente indistinguible para un lector).
Por qué analizar DOCX no es tan amigable como analizar HTML
DOCX es un ZIP que contiene XML. El esquema es OOXML (ECMA-376) y python-docx lo envuelve en un agradable modelo de objetos de párrafo/run. El problema es que la mayor parte de lo que hace interesante a un documento de Word real vive fuera del amigable modelo de objetos:
- Los saltos de página son elementos
<w:br w:type="page"/>incrustados dentro de runs. Sí aparecen enparagraph.runs, pero como efecto secundario un\nse cuela en el texto del run: cuéntalo una vez y tendrás un salto de línea perdido. - Los hipervínculos viven en elementos
<w:hyperlink>que son hermanos de los elementos<w:r>dentro del párrafo.paragraph.runslos omite por completo: itera solo los runs y el texto del enlace desaparece (o se queda, pero se pierde la URL). - Las propiedades de sección (tamaño de página, encabezados, pies de página) cuelgan de
sections[*].header / .footercon banderas de herencia en cascada (is_linked_to_previous) que la API amigable resuelve en silencio. - El interlineado es un multiplicador flotante en
paragraph_format.line_spacing; volver a asignarlo a los tokens discretos del editor (tight / normal / relaxed / loose / double) requiere una pasada de ajuste al más cercano.
Si tu importación de DOCX solo usa paragraph.runs, tu documento pierde silenciosamente
cada hipervínculo y cada salto de página en el instante en que toca tu pipeline. Ambos hacen el
recorrido de ida y vuelta como texto plano o desaparecen por completo. Nos topamos con ambos errores
en la primera ejecución de integración.
La estructura: import_docx_bytes devuelve seis claves
Antes de este trabajo, nuestra ruta de importación devolvía una tupla de 2 (markdown_body,
formatting_array): el contenido del cuerpo más los desplazamientos de formato independientes.
Eso estaba bien para documentos de solo cuerpo, pero perdía cualquier encabezado/pie de página creado por el autor.
Cambiamos el tipo de retorno a un diccionario de seis claves que refleja el modelo de almacenamiento del editor:
{
"body_md": "# Heading\n\nFirst paragraph...",
"body_formatting": [{"start": 0, "end": 9, "classes": "font-playfair"}, …],
"header_md": "Confidential — Q3 2026",
"header_formatting": [{"start": 0, "end": 11, "classes": "decoration-bold"}],
"footer_md": "Page",
"footer_formatting": [],
}
Cada *_md usa su propio espacio de índice basado en cero; los desplazamientos del
encabezado/pie de página no se comparten con el cuerpo. Las tres pasadas comparten un mismo
ayudante (walk_container) que reinicia md_parts,
formatting y el cursor al entrar, para que no se filtren desplazamientos entre sí.
Tomamos solo sections[0] (el editor impone un encabezado/un pie de página por
documento) y respetamos is_linked_to_previous: cuando una sección hereda de una
anterior (el valor predeterminado para sections[0]) no tiene encabezado/pie de página creado por
el autor y aportamos cadenas vacías. Todo el bloque del encabezado/pie de página está envuelto en
try / except, de modo que un sectPr malformado se degrada a "sin encabezado/pie de
página" en lugar de hacer fallar toda la importación.
Saltos de página: recorrer el XML en bruto
python-docx expone el texto de un run como run.text: una concatenación de sus
hijos <w:t> con los <w:br> colapsados a \n.
Eso nos da el texto pero pierde la distinción entre un salto suave y un salto de página forzado.
Solución: recorrer paragraph._element.iter(qn("w:br")) en cada párrafo y comprobar
el atributo w:type de cada salto. Cuando w:type == "page", emitimos nuestro
marcador [PAGE BREAK]\n\n antes del contenido del párrafo, de modo que un
salto-de-página-antes-de-encabezado en Word sobrevive como [PAGE BREAK]\n\n# Heading en
nuestro markdown. Si el párrafo no contiene nada más que el salto, omitimos el bloque final vacío para
evitar dobles saltos de línea perdidos, y eliminamos el \n inducido por el salto de página
del texto del run para que el marcador no se cuente dos veces.
Hipervínculos: iterar directamente los hijos XML del párrafo
Para los hipervínculos, el truco es renunciar por completo a paragraph.runs e iterar
los hijos XML del párrafo, despachando según la etiqueta:
for child in paragraph._element:
tag = etree.QName(child).localname
if tag == "r":
emit_run(child)
elif tag == "hyperlink":
emit_hyperlink(child)
emit_hyperlink resuelve r:id contra la tabla de relaciones del párrafo
para obtener la URL externa, con un respaldo a #anchor para los hipervínculos de
enlace interno (entradas de TOC que apuntan a marcadores de encabezado), de modo que la estructura
sobrevive incluso cuando no podemos resolver el marcador. Emitimos markdown nativo
[text](url): la ruta de enlaces markdown ya existente del renderizador produce un
verdadero <a href> sin una pasada posterior aparte.
El estilo interno dentro del hipervínculo (texto de enlace en negrita, texto de enlace coloreado)
pasa por la misma canalización de emit_run que los runs normales, de modo que un
enlace azul en negrita sigue siendo un enlace azul en negrita en el editor.
Interlineado: ajuste al más cercano, en ambas direcciones
El editor no acepta valores flotantes arbitrarios de interlineado: tiene cinco tokens
(tight / normal / relaxed / loose / double) que se asignan a 1.2 / 1.6 / 2.0
/ 2.5 / 3.0 respectivamente. Al ir del editor a DOCX, la asignación es directa.
La vuelta es más difusa: un documento de Word creado con interlineado 1.5x (típico del cuerpo del
texto) debería hacer el recorrido de ida y vuelta hacia linespacing-normal, no ser rechazado.
La implementación es la inversa del _nearest_size_token del lado de la exportación: una
pasada de ajuste al más cercano contra los mismos cinco valores de referencia. El resultado se emite
como un bloque de atributo a nivel de párrafo {: .linespacing-X } junto con la alineación
y la sangría.
La prueba de ida y vuelta
Las conversiones bidireccionales son fáciles de romper y difíciles de detectar. Añadimos
backend/tests/manual_docx_roundtrip.py: un script de auditoría manual (en el espíritu
de las otras auditorías manual_*.py) que hace:
doc = build_test_document()
docx_1 = generate_docx_bytes(doc) # Editor -> Word
parsed = import_docx_bytes(docx_1) # Word -> Editor
docx_2 = generate_docx_bytes(parsed) # Editor -> Word again
assert paragraph_count(docx_1) == paragraph_count(docx_2)
assert heading_levels(docx_1) == heading_levels(docx_2)
assert run_properties(docx_1) == run_properties(docx_2)
No es una prueba de pytest: se ejecuta contra el contenedor de backend en vivo e inspecciona el XML OOXML real de los dos archivos docx generados. La salida son diffs legibles por humanos de lo que cambió a lo largo de las dos pasadas. Añadimos una nueva aserción cada vez que se descubre una regresión, de modo que la próxima vez que aparezca el mismo caso límite, la auditoría falla a viva voz en lugar de perderse silenciosamente en un documento complejo.
Lo que todavía es imperfecto
- Secciones múltiples. Un documento con saltos de sección (encabezados distintos en el capítulo 1 frente al capítulo 2) se aplana al chrome de la primera sección. El modelo de datos del editor solo admite un encabezado/un pie de página por documento, y cambiar eso es una cirugía mucho mayor que la ruta de importación por sí sola.
- Comentarios y cambios rastreados. Ambos están presentes en el OOXML, pero hoy los dejamos caer al suelo. El editor no tiene interfaz para ninguno de los dos, así que importarlos solo significaría descartarlos en el siguiente guardado de todos modos.
- Imágenes. Sí extraemos las referencias de imagen, pero solo como marcadores de posición reenlazados. Sacar los bytes reales de la imagen de la carpeta de medios del DOCX, persistirlos y reescribir la referencia de la imagen en el markdown es la próxima pasada.
- Estilos personalizados. Un documento que usa un estilo de Word personalizado ("Cita con sangría de cuerpo") que no es uno de los estilos conocidos del editor recibe la coincidencia más cercana de nuestra tabla de estilos. Un recorrido de ida y vuelta autoritativo de estilos personalizados arbitrarios requeriría arrastrar sus definiciones a través del modelo de datos del editor, lo cual no hacemos.
Qué cambia esto para los usuarios
El flujo de trabajo estrella ahora es simétrico. Puedes:
- Tomar un documento de Word existente: un borrador de carta, un artículo de investigación, un contrato.
- Subirlo a AgentDoc (un solo clic en el botón "Importar .docx" de la barra lateral, o
un solo
POST /api/docs/import/docxpara agentes autónomos; consulta la documentación de agentes). - Editarlo hablando o por chat, con el agente haciendo ediciones estructuradas mientras el diseño de la página se mantiene exactamente como lo creaste.
- Exportarlo de nuevo a Word, y tu colaborador abre el archivo en el mismo Word con el que empezó, con las mismas fuentes, los mismos colores, la misma geometría de página.
Para el flujo de trabajo del lado del agente en concreto, esto también cierra el círculo donde un
cliente MCP autónomo solo podía construir documentos desde cero. Con import_docx_bytes
conectado, un agente puede ingerir un DOCX con plantilla (p. ej., un membrete corporativo con
campos rellenados previamente), impulsar las ediciones a través de la superficie de herramientas
MCP y exportar el resultado: exactamente el tipo de caso de uso de "rellenar este formulario" donde
volver a escribir desde cero es el cuello de botella.
Lecturas complementarias
- Reconstruyendo la exportación a PDF + DOCX: el espejo de esta entrada en el camino de salida del editor.
- Granularidad de herramientas en agentes LLM: el marco de diseño de herramientas que impulsa la superficie MCP que los agentes autónomos usan sobre los documentos importados.