Importación de DOCX: documentos de Word de ida y vuelta por un editor con IA

Ingeniería · 9 min de lectura

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:

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:

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

Qué cambia esto para los usuarios

El flujo de trabajo estrella ahora es simétrico. Puedes:

  1. Tomar un documento de Word existente: un borrador de carta, un artículo de investigación, un contrato.
  2. Subirlo a AgentDoc (un solo clic en el botón "Importar .docx" de la barra lateral, o un solo POST /api/docs/import/docx para agentes autónomos; consulta la documentación de agentes).
  3. 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.
  4. 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 Todas las entradas →