Mostrando entradas con la etiqueta Datos. Mostrar todas las entradas
Mostrando entradas con la etiqueta Datos. Mostrar todas las entradas

miércoles, 6 de mayo de 2026

DATOS. Archivos de texto

Archivos de texto

Acceso a contenidos específicos

No tengo muy claro si esta entrada debe situarse en esta subsección (Acceso a datos) o si debería ubicarse en la que sigue (Limpieza de datos), entendida en sentido amplio, pero posiblemente esto no es lo más importante. Lo que interesa es el concepto que se trata en ella y lo que implica. Antes de decir algo más al respecto, es necesario advertir que, en cualquier caso, ahora sólo trataremos estas cuestiones en términos muy básicos y limitados, ya que será necesario retomar el tema cuando hablemos de organización y limpieza de los datos no estructurados y, como no puede ser de otro modo, también en la subsección Uso de datos.

En las entradas anteriores hemos estado tratando cuestiones relativas al acceso a los datos de los textos como un todo, diferenciando si este contenido se encuentra ubicado en tablas o si conforma párrafos más o menos extensos. Esta diferención lo es también de datos vs. textos, o si se preferie (y yo sí lo prefiero) entre datos semi-estructurados frente a datos no estructurados. En sentido estricto, ningun documento contiene datos estructurados, así que lo más parecido a éstos son los datos semi-estructurados que encontramos en las tablas y en las tablas-formulario de los documentos. Pero ahora nos hemos alejado de estos entornos y nos hemos enfrentado al texto en sentido estricto, lo que equivale a decir a los datos no estructurados; y ante estos el mero acceso (y "captura") sólo puede ser una primera (y muy necesaria) fase de un proceso considerablemente complejo que requiere varios subprocesos. Lo que ahora abordamos sería un intento de concretar una opción de posible segunda fase.

Y es que de poco sirve capturar un texto si no obtenemos información de él. Pasa lo mismo cuando hablamos del tratamiento analógico de los textos, pero es mucho más definitorio (y difícil de llevar a cabo) cuando el tratamiento es digital. Podemos concretarlo de muchas formas, así que la que ahora voy a proponer es sólo una de ellas, y no precisamente de las de mayor complejidad.

En el marco del acceso al contenido de los textos de una colección de informes psicopedagógicos, deseamos identificar si en ellos se hace alguna mención al uso del test (batería) WISC. Esto implica interés por saber si este recurso se ha empleado, con qué peso cuantitativo en la muestra documental y qué información se asocia con ese uso.

El objetivo simple, identificar uso vs. no-uso del WISC, no parece ofrecer mayor dificultad, pero acceder al contenido textual asociado a esta etiqueta conlleva cierto nivel de complejidad, que nos proponemos abordar de la forma más directa y simple posible, lo que no garantiza que sea la mejor, aunque según cual sea nuestro objetivo puede ser suficiente. Sí lo es para el objetivo que nos planteamos en esta entrada.



import os
import csv
from odf.opendocument import load
from odf import text

# --- Función secundaria 1: Extracción lineal de párrafos ignorando tablas. ---

def extraer_parrafos_no_tablas(ruta_archivo):

    try:
        doc = load(ruta_archivo)
        parrafos_limpios = []
        for p in doc.getElementsByType(text.P):
            parent = p.parentNode					 # Filtro de tablas por ancestros
            dentro_de_tabla = False
            while parent is not None:
                if parent.tagName == "table:table":
                    dentro_de_tabla = True
                    break
                parent = parent.parentNode
            
            if not dentro_de_tabla:
                contenido = "".join(node.data for node in p.childNodes if node.nodeType == 3)	 # Unimos todos los nodos de texto
                if contenido.strip():
                    parrafos_limpios.append(contenido.strip())
        return parrafos_limpios
    except Exception as e:
        print(f"   [!] Error de lectura en {os.path.basename(ruta_archivo)}: {e}")
        return []

# --- Función secundaria 2: Captura el párrafo del hallazgo y los N siguientes para asegurar el contexto. . ---

def extraer_segmento_contiguo(lista_parrafos, termino, num_posteriores=4):
   
    segmentos_completos = []
    indices_ya_incluidos = set()

    for i, texto in enumerate(lista_parrafos):
        if termino.lower() in texto.lower() and i not in indices_ya_incluidos:
            rango_fin = min(i + num_posteriores + 1, len(lista_parrafos))		# Iniciamos bloque
            bloque = lista_parrafos[i:rango_fin]
            
            for j in range(i, rango_fin):		 # Marcamos para no repetir si el término aparece en los párrafos de este bloque
                indices_ya_incluidos.add(j)
            
            segmentos_completos.append("\n\n".join(bloque))
            
    return segmentos_completos

# --- Función principal ---

def procesar_informes(dir_entrada, csv_salida, palabra_clave):
    if not os.path.exists(dir_entrada):
        print(f"La ruta {dir_entrada} no existe.")
        return

    archivos = [f for f in os.listdir(dir_entrada) if f.lower().endswith('.odt')]
    resultados = []

    print(f"Procesando {len(archivos)} archivos...\n")

    for nombre in archivos:
        ruta = os.path.join(dir_entrada, nombre)
        parrafos = extraer_parrafos_no_tablas(ruta)		# Llamada a función secundaria 1
        
        hallazgos = extraer_segmento_contiguo(parrafos, palabra_clave, num_posteriores=4)	 # Llamada a función secundaria 2
        
        estado = "PRESENTE" if hallazgos else "AUSENTE"
        print(f"[{estado}] {nombre}")
        
        resultados.append({
            'Archivo': nombre,
            'Hallazgo': estado,
            'Texto': " |SECCIÓN SIGUIENTE| ".join(hallazgos) if hallazgos else "N/A"
        })

    try:
        with open(csv_salida, mode='w', newline='', encoding='utf-8') as f:
            writer = csv.DictWriter(f, fieldnames=['Archivo', 'Hallazgo', 'Texto'])
            writer.writeheader()
            writer.writerows(resultados)
        print(f"\nCSV generado con éxito en: {csv_salida}")
    except Exception as e:
        print(f"Error al escribir CSV: {e}")

# --- Llamada a función ---

if __name__ == "__main__":
    RUTA_ARCHIVOS = r"ruta_de_directiro_de_documentos"		# Ruta de acceso a la colección de documento
    RUTA_CSV = r"ruta_destino_y_nombre_archivo.csv"			# Ruta para ubicación de csv e identificador del archivo
    TERMINO = "WISC"										# Término objeto de estudio

    procesar_informes(RUTA_ARCHIVOS, RUTA_CSV, TERMINO)


Lo que hacemos con este script es, en primer lugar prescindir de las tablas del documento y de los datos que éstas contienen para centrarnos en la extracción de los textos (párrafos). Dentro de ellos, buscamos la aparición del término-diana (aquí WISC) y lo relacionamos con su contexto (texto inmediatamente próximo al término-diana) para capturar el contenido que se asocia al mismo- Esto nos permite acceder no sólo a información sobre la presencia vs. ausencia del término-diana en el documento, sino también al contenido asociado a dicho término, lo que supone una mayor complejidad del procedmiento de automatización, pero también que el script es capaz de facilitar información que incrementa significativamente nuestra capacidad de análisis de los documentos. No estamos en condiciones para asegurar que esto sea suficiente para objetivos complejos, pero sí para muchos otros más simples. En todo caso sólo estamos ante un procedimiento básico. En otras subsecciones trataremos cómo desarrollar otras.

Presento también el primer script de una serie de tres (el anterior es el tercero) como punto de referencia para comprender la línea de desarrollo que se pretende explictar y que se concreta fundamentalmente como incremento del contexto textual que rodea al término-diana.



import os
import csv
from odf.opendocument import load
from odf import text

#--- Función secundaria. Filtar tablas ---

def extraer_parrafos_no_tablas(ruta_archivo):
    try:
        doc = load(ruta_archivo)
        parrafos_validos = []
        for p in doc.getElementsByType(text.P):
            dentro_de_tabla = False
            parent = p.parentNode
            while parent is not None:
                if parent.tagName == "table:table":
                    dentro_de_tabla = True
                    break
                parent = parent.parentNode
            if not dentro_de_tabla:
                contenido = "".join(node.data for node in p.childNodes if node.nodeType == 3)
                if contenido.strip():
                    parrafos_validos.append(contenido.strip())
        return parrafos_validos
    except Exception as e:
        print(f"Error al leer el archivo {ruta_archivo}: {e}")
        return []
        
# --- Función principal --- 

def procesar_coleccion_odt(directorio_entrada, ruta_csv, termino):
    print(f"Buscando en: {directorio_entrada}...")
    
    if not os.path.exists(directorio_entrada):
        print(f"ERROR: La carpeta no existe: {directorio_entrada}")
        return

    archivos = [f for f in os.listdir(directorio_entrada) if f.lower().endswith('.odt')]
    print(f"Se han encontrado {len(archivos)} archivos .odt")

    if len(archivos) == 0:
        print("No hay nada que procesar. Revisa la extensión de los archivos.")
        return

    resultados = []
    for nombre_archivo in archivos:
        ruta_completa = os.path.join(directorio_entrada, nombre_archivo)
        parrafos = extraer_parrafos_no_tablas(ruta_completa)
        hallazgos = [p for p in parrafos if termino.lower() in p.lower()]
        
        presencia = "PRESENTE" if hallazgos else "AUSENTE"
        print(f"Procesado: {nombre_archivo} -> {presencia}")
        
        resultados.append({
            'Nombre del Archivo': nombre_archivo,
            'Presencia Termino': presencia,
            'Contenido Extraído': " | ".join(hallazgos) if hallazgos else "N/A"
        })

    try:
        with open(ruta_csv, mode='w', newline='', encoding='utf-8') as f:
            writer = csv.DictWriter(f, fieldnames=['Nombre del Archivo', 'Presencia Termino', 'Contenido Extraído'])
            writer.writeheader()
            writer.writerows(resultados)
        print(f"\nÉXITO: Archivo creado en {ruta_csv}")
    except Exception as e:
        print(f"ERROR al crear el CSV: {e}")

# --- LLAMADA AL SCRIPT ---

if __name__ == "__main__":
    RUTA_DOCS = r"ruta_archivos_a_procesar"				 # Definición de variables de configuración
    RUTA_OUT  = r"ruta_del_archivo.csv"
    OBJETIVO  = "WISC"

    procesar_coleccion_odt(RUTA_DOCS, RUTA_OUT, OBJETIVO)
    

Para comprender lo que conseguimios con estos archivos, nada mejor que un breve estudio de resultados, aunque sin ninguna pretensión de representatividad; tan sólo a nivel de indicio. Analizo a continuación los 14 primeros archivos de una colección significativamente más amplia, pero que ahora no interesa ya que en esta entrada no pretendo realizar ningún estudio (cosa que puede quedar pendiente para otro momento).

De estos 14 archivos, sólo en tres se identifica el término-diana (WISC), lo que ahora es prácticamente irrelevante, pero que dejaría de serlo en un estudio orientado a valorar el grado de uso de un determinado recurso como es la escala WISC en sus diferentes versiones. Lo que sí es relevante ahora es comprender las diferencias que se aprecian en términos de recuperación de la información asociada al término-diana entre el primero y el tercero de los script que se desarrollaron para logar esta meta. De paso, es necesario destacar que existe una diferencia importante entre este modelo de trabajo y lo que sería la mera identificación de la presencia del término. Veamos esa evolución:

Vemos que existen notables diferencias entre los tres archivo, las cuales se deben fundamentalmente al peso real de los datos asociados al término-diana en cada uno de los documento (otra parte se puede entender asociada al mayor o menos éxito del procedimiento de obtención de datos); pero lo que resulta más importante es la evolución entre el primer y el tercer script, que en este gráfico se presenta en términos absolutos (número de palabras por segmento textual y versión del script.

Si lo expresamos en términos porcentuales, algunos datos pueden contribuir a la mejor comprensión del resultado:

  1. Salvo en el primer documento, en los otros dos el incremento obedece al esquema [Tv1 menor que Tv2 menor que Tv3]. En ese primer documento, se aprecia una ligera disminución del número de palabras, pero también es ese documento el que mayor incremento presenta entre el Tv1 y Tv2 (concretamente algo más del 466%)
  2. Esto quiere decir que el script que mejores resultados ofrece (como cabe esperar) el tercero (salvo para el primer documento, que es el segundo script).
  3. La ganancia que se obtiene con este tercer script respecto al primero es evidente: más del 400% en el documento de menos impacto, pero por encima del 700% en los otros dos, concretamente el 788% en uno y algo más del 900% en el otro.

Dado que no se trata de un estudio con intención de ser representativo, estos datos no se deben extrapolar, pero son suficientemente reveladores que lo que interesa mostrar aquí: podemos desarrollar procedimientos que facilitan el acceso selectivo a datos relativos a determinado término, aunque faltan datos que permitan identificar con propiedad la calidad de estos resultados. También esta importante cuestión queda para un análisis específico y para el desarrollo de otros procedimientos alternativos.

DATOS. Archivos de texto

Archivos .odt

Acceso a tablas y textos

Aunque no aporte mucho a lo que ya sabemos, no podemos dejar de exponer los procedimientos que nos permiten acceder al contenido de los archivos .odt, dada la frecuencia con la que trabajamos con estos archivos en este blog. Así vamos a replicar aquí lo que ya hicimos en esta entrada con los archivos .docx.



import os
import sys
import io
from odf import text, table, teletype
from odf.opendocument import load

sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')

# --- Función ----

def leer_contenido_odt(ruta_archivo):
    if not os.path.exists(ruta_archivo):				    # Controla que existe el archivo
        print(f"Error: El archivo '{ruta_archivo}' no existe.")
        return

    try:
        doc = load(ruta_archivo)				    		# Cargar el documento ODT
    except Exception as e:
        print(f"Error al cargar el archivo: {e}")
        return
    
    print(f"--- INICIO DEL CONTENIDO: {ruta_archivo} ---\n")

    contenido_principal = doc.text				 		 # Accedemos al texto

    for elemento in contenido_principal.childNodes:
        
        if elemento.tagName in ["text:p", "text:h"]:		# 1. Párrafos y Encabezados (h = heading, p = paragraph)
            texto = teletype.extractText(elemento).strip()
            if texto:
                etiqueta = "TÍTULO" if elemento.tagName == "text:h" else "PÁRRAFO"  # Diferenciamos si es título o párrafo
                print(f"[{etiqueta}]: {texto}")
        elif elemento.tagName == "table:table":				# 2. Tablas
            filas = elemento.getElementsByType(table.TableRow)
            nombre_tabla = elemento.getAttribute("name") or "Tabla"
            
            print(f"\n[--- INICIO {nombre_tabla} ---]")
            for fila in filas:
                celdas = fila.getElementsByType(table.TableCell)					# Extraemos el texto de cada celda
                contenido_fila = [teletype.extractText(celda).strip() for celda in celdas]
                if any(contenido_fila):												# Solo imprimimos la fila si tiene algún contenido
                    print(" | ".join(contenido_fila))
            print(f"[--- FIN {nombre_tabla} ---]\n")

    print(f"\n--- FIN DEL DOCUMENTO ---")

# --- Llamada a función ---

if __name__ == "__main__":
    nombre_archivo = r"ruta_de_mi_archivo.odt"  # Aquí el nombre del archivo .odt
    leer_contenido_odt(nombre_archivo)			# Llamada a la función


Ahora toca identificar sólo las tablas...


  
import os
import sys
import io
from odf import table, teletype
from odf.opendocument import load

# Configuración para que la consola de Windows maneje correctamente caracteres especiales
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')

# --- Función de extracción de tablas ---

def extraer_tablas_odt(ruta_archivo):

    if not os.path.exists(ruta_archivo):							# Nombre del archivo a procesar
        print(f"Error: El archivo '{ruta_archivo}' no existe.")
        return
    
    try:															# Cargar el documento ODT
        doc = load(ruta_archivo)
    except Exception as e:
        print(f"Error al cargar el archivo: {e}")
        return
    
    print(f"--- EXTRACCIÓN DE TABLAS: {ruta_archivo} ---\n")

    # Buscamos todas las tablas en el documento mediante getElementsByType()
    tablas = doc.getElementsByType(table.Table)

    if not tablas:
        print("No se encontraron tablas en el documento.")
        return

    for i, t in enumerate(tablas):
        nombre_tabla = t.getAttribute("name") or f"Tabla {i+1}"
        print(f"[--- INICIO {nombre_tabla} ---]")
        
        filas = t.getElementsByType(table.TableRow)
        for fila in filas:
            celdas = fila.getElementsByType(table.TableCell)
            
            contenido_fila = [teletype.extractText(celda).strip() for celda in celdas]	# Extraemos el texto de cada celda
            
            if any(contenido_fila):														# Imprimimos la fila solo si contiene algún texto
                print(" | ".join(contenido_fila))
        
        print(f"[--- FIN {nombre_tabla} ---]\n")

    print(f"--- FINALIZADO ---")
    
# --- Llamada a la función ---

if __name__ == "__main__":
    nombre_archivo = r"ruta_de_mi_archivo.odt" 	# Aquí la ruta del archivo
    extraer_tablas_odt(nombre_archivo)			# Llamada a la función
    

Para finalizar vamos a mostrar el script que únicamente accede a los párrafos del documento, omitiendo el contenido de las tablas y de tablas-formulario.



import os
import sys
import io
from odf import text, teletype
from odf.opendocument import load

# Configuración para que la consola de Windows maneje correctamente caracteres especiales
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')

# --- Función de extracción de texto (párrafos) ---

def leer_solo_parrafos_odt(ruta_archivo):
  
    if not os.path.exists(ruta_archivo):							 # Verificar si el archivo existe
        print(f"Error: El archivo '{ruta_archivo}' no existe.")
        return

    try:
        doc = load(ruta_archivo)									# Cargar el documento ODT
    except Exception as e:
        print(f"Error al cargar el archivo: {e}")
        return
    
    print(f"--- CONTENIDO TEXTUAL (SIN TABLAS): {ruta_archivo} ---\n")

    contenido_principal = doc.text									# Accedemos al elemento de texto del cuerpo

    for elemento in contenido_principal.childNodes:					# Iteramos sobre los elementos del cuerpo
        
        if elemento.tagName in ["text:p", "text:h"]:				# Filtramos: Solo nos interesan párrafos (p) y encabezados (h)
            texto = teletype.extractText(elemento).strip()
            
            if texto:												# Solo imprimimos si el párrafo no está vacío
                # Opcional: puedes quitar la etiqueta [PÁRRAFO] si quieres el texto limpio
                etiqueta = "TÍTULO" if elemento.tagName == "text:h" else "PÁRRAFO"
                print(f"[{etiqueta}]: {texto}")

    print(f"\n--- FIN DEL DOCUMENTO ---")

# --- Llamada a la función ---

if __name__ == "__main__":
    nombre_archivo = r"ruta_de_mi_archivo.odt" 		# Aquí la ruta del archivo a trabajar 
    leer_solo_parrafos_odt(nombre_archivo)			# Llamada a la función


martes, 5 de mayo de 2026

DATOS. Archivos de texto

Archivos .docx

Acceso a tablas y textos

Dado que ya hemos hablado de los archivos de texto .docx (por ejemplo, aquí), resulta un poco complicado plantear esta entrada sin que resulte repetitiva, aunque el intento no carece de sentido. En aquel momento hablamos de los documentos .docx desde una perspectiva de generación de los mismos (aunque se trataran también otros temas), y ahora nuestro interés se centra en la obtención de datos de documentos ya creados, fundamentalmente datos textuales y contenidos contenidos en tablas, lo que equivale a decir datos no estructurados y datos que aspiran a ser considerados como datos estructurados, pero que aun no alcanzan este nivel de organización (datos semi-estructurados).

La biblioteca python-docx, ya presentada en relación con la temática antes señalada será también la herramienta que nos ayude a acceder al contenido de los documentos .docx, que son una evolución compleja de los .doc primitivos, implementando la arquitectura XML, lo que facilita el manejo del contenido mediate script Python, que se hace precisamente manejando esos componentes xml.

A nosotros lo que realmente nos importa ahora, desde la perspectiva de esta subsección, es el acceso al contenido, diferenciando si este se presenta en formato de tabla (mejor tabla-formulario), que, como dije, se considera como dato semi-estructurado, aunque no siempre lo sea, frente al texto no-tabla (párrafos), que se consideran datos no estructurados. Respecto a las tablas, dado que también se emplean para formatear la presentación del contenido, en algunas ocasiones éste no es en realidad un dato semi-estructurado; se trata de un segmento textual no estructuardo y como tal debe ser tratado. A veces esta diferencia es de detalle, lo que da lugar a confusiones en el planeamiento de cómo debe ser tratado en los procesos de limpieza y de análisis.

No es esta una cuestión que sólo afecte a los documentos .docx, pero lo trataremos aquí para avanzar propuestas, dado que es una cuestión de importancia dentro de la subsección en la que nos encontramos en este punto.Pero antes de abordar esta cuestión resolvamos primero las más básicas: el acceso al contenido en su conjunto y el acceso al contenido de forma diferenciada: tablas y textos.



import docx
import os

def leer_contenido_docx(ruta_archivo):
    if not os.path.exists(ruta_archivo):			    		# Verificar si el archivo existe
        print(f"Error: El archivo '{ruta_archivo}' no existe.")
        return
    doc = docx.Document(ruta_archivo)					  		# Cargar el documento

    print(f"--- INICIO DEL CONTENIDO: {ruta_archivo} ---\n")
    
    for elemento in doc.element.body:							# Acceso secuencial a todos los elementos del cuerpo del documento
        if isinstance(elemento, docx.oxml.text.paragraph.CT_P): # 1. Si el elemento es un párrafo (CT_P)
            p = docx.text.paragraph.Paragraph(elemento, doc)
            if p.text.strip(): # Evitar mostrar líneas vacías si se desea
                print(f"[PÁRRAFO]: {p.text}")
        elif isinstance(elemento, docx.oxml.table.CT_Tbl):		# 2. Si el elemento es una tabla (CT_Tbl)
            tabla = docx.table.Table(elemento, doc)
            print(f"\n[TABLA INICIO - {len(tabla.rows)} filas x {len(tabla.columns)} columnas]")
            for fila in tabla.rows:
                contenido_fila = [celda.text.strip() for celda in fila.cells]  # Extraemos el texto de cada celda
                print(" | ".join(contenido_fila))
            print("[TABLA FIN]\n")

    print(f"\n--- FIN DEL DOCUMENTO ---")
    
# --- Llamada a la función ---

if __name__ == "__main__":
    nombre_archivo = r"Directorio_de_mi_archivo.docx" 	# Aquí la ruta de tu_archivo.docx'
    leer_contenido_docx(nombre_archivo)


Este script permite acceder a un documento .docx, identifica su contenido, diferenciando tablas de párrafos y muestra todo ese contenido en el CMD señalando la diferencia entre el procedente de una tabla del identificado como párrafo. Esta diferenciación ya señala la posibilidad de acceder a datos semi-estructurado (tablas y tablas-formulario) y a datos no estructurados (párrafos) de forma diferenciada. El siguiente script permite el acceso a las tablas del documento y a su contenido. A partir de aquí podríamos desarrollar procedimientos específicos de acceso a datos concretos. La idea a posteriori es facilitar la transformación de estos datos semi-estructurados en datos estructurados, para tratarlo con los procedimiento del organización y limpieza, y de análisis acordes con los datos de esa naturaleza.



import docx
import os

#Función principal: exploración de las tablas del documento

def leer_tablas_docx(ruta_archivo):
    if not os.path.exists(ruta_archivo):
        print(f"Error: El archivo '{ruta_archivo}' no existe.")
        return

    doc = docx.Document(ruta_archivo)

    print(f"--- RELACIÓN DE TABLAS ENCONTRADAS ---")
    
    tablas_limpias = []
    
    for i, tabla in enumerate(doc.tables):
        # Extraemos el nombre/título desde las propiedades XML de la tabla (tblPr)
        nombre_tabla = None
        try:
            # Buscamos en el elemento XML de la tabla el tag de título (tblCaption)
            nombre_tabla = tabla._element.xpath('.//w:tblCaption/@w:val')
            if nombre_tabla:
                nombre_tabla = nombre_tabla[0]
            else:
            # Intentamos con la descripción si el título falla
                nombre_tabla = tabla._element.xpath('.//w:tblDescription/@w:val')
                if nombre_tabla: nombre_tabla = nombre_tabla[0]
        except:
            nombre_tabla = None

        if not nombre_tabla:
            nombre_tabla = f"Tabla_{i+1}"
        
        tablas_limpias.append((nombre_tabla, tabla))
        print(f"ID: {i+1} | Nombre en Navegador: {nombre_tabla}")
    
    print("-" * 40 + "\n")

    for nombre, tabla in tablas_limpias:		# Mostramos el contenido de las tablas
        print(f"\n[CONTENIDO TABLA]: {nombre}")
        print("-" * (18 + len(nombre)))
        
        for fila in tabla.rows:
            textos_celdas = []			# Usamos un set para evitar celdas duplicadas en caso de celdas combinadas
            for celda in fila.cells:
                texto = celda.text.strip().replace('\n', ' ')
                textos_celdas.append(texto) 
            print(" | ".join(textos_celdas))
        print("-" * 20)
        
# --- Llamada a la función ---

if __name__ == "__main__":
    nombre_archivo = r"ruta_de_tu_archivo.docx" 	# Aquí tu ruta de archivo
    leer_tablas_docx(nombre_archivo)				# Llamada a la función


Mediante este otro script mostramos los párrafos (datos no estructurados), los cuales posteriormente deberan ser tratados con los procedimientos correspondiente. Es interesante observar que cada párrafo queda identificado por su posición, lo que puede facilitar su localización, aunque como procedimiento es poco funcional si pensamos en una obtención masiva de determinado contenido. De momento, algo es algo.



import docx
import os

def leer_texto_no_tabular(ruta_archivo):
    if not os.path.exists(ruta_archivo):				# Verificar si el archivo existe
        print(f"Error: El archivo '{ruta_archivo}' no existe.")
        return

    doc = docx.Document(ruta_archivo)		 			# Cargar el documento

    print(f"--- INICIO DEL CONTENIDO NO TABULAR: {ruta_archivo} ---\n")

    contador_parrafos = 0						 		# doc.paragraphs solo itera por los párrafos
    
    for i, para in enumerate(doc.paragraphs):
        texto = para.text.strip()						# Limpiamos espacios en blanco al inicio y final
        
        if texto:										# Omitir párrafos vacíos para una salida más limpia en CMD
            contador_parrafos += 1
            print(f"[{contador_parrafos}]: {texto}")	# Mostramos el índice del párrafo para facilitar el seguimiento
            print("-" * 10) # Separador visual opcional

    if contador_parrafos == 0:
        print("No se encontró texto fuera de tablas en este documento.")

    print(f"\n--- FIN DEL DOCUMENTO (Total párrafos: {contador_parrafos}) ---")

# --- Llamada a la función ---

if __name__ == "__main__":
    nombre_archivo = r"ruta_de_mi_documento.docx" 	 # Aquí tu archivo
    leer_texto_no_tabular(nombre_archivo)			 # Llamada a la función


DATOS. Directorios y archivos

Subdirectorios

En cuanto un directorio contiene un determinado número de archivos, y éstos se pueden diferenciar por algún criterio, es habitual que el directorio se divida, generándose una estructura de subdirectorios. Un caso concreto de esto son los expedientes SEO. Además de ser una tendencia lógica, que esto se produzca es una medida de la madurez del expediente y de la funcionalidad que éste tiene como recurso de intervención del SEO. Por ello el estudio de la estructura debe ser parte del análisis de los expedientes SEO. Este estudio se puede automatizar mediante script.

Voy a presentar en esta entrada algunos script dirigidos a facilitar la automatización en este nivel, empezando por la identificación del número de subdirectorios que contiene un listado de directorios.



from pathlib import Path
import csv
from collections import Counter

#Función secundaria. Guarda el listado detallado en un archivo CSV.

def crear_csv(datos, ruta_destino):
    nombre_archivo = Path(ruta_destino) / "listado_directorios.csv"
    try:
        with open(nombre_archivo, mode='w', newline='', encoding='utf-8') as f:
            escritor = csv.writer(f)
            escritor.writerow(["Directorio", "Num_Subcarpetas"])
            for fila in datos:
                escritor.writerow([fila['nombre'], fila['subs']])
        print(f"\n[SISTEMA] Archivo guardado en: {nombre_archivo}")
    except Exception as e:
        print(f"\n[ERROR] No se pudo crear el CSV: {e}")

# Función secundaria. Calcula y muestra la frecuencia de subdirectorios.

def generar_sintesis(datos):
    total_directorios = len(datos)
    if total_directorios == 0:
        return
    frecuencias = Counter(d['subs'] for d in datos)
    
    print("\n--- RESUMEN ESTADÍSTICO DE FRECUENCIAS ---")
    for num_subs, cantidad in sorted(frecuencias.items()):
        porcentaje = (cantidad / total_directorios) * 100
        print(f"CARPETAS con {num_subs} subdirectorios : {cantidad} carpetas -- {porcentaje:.2f}% sobre total")

# Función principal: Coordina el listado, el CSV y la estadística.

def procesar_ruta(ruta_analizar, ruta_guardado):
    path_base = Path(ruta_analizar)
    
    if not path_base.exists() or not path_base.is_dir():
        print("La ruta proporcionada no es válida.")
        return

    resultados = []

    print(f"\nListado de directorios en: {path_base.resolve()}\n")
    
    for item in path_base.iterdir():
        if item.is_dir():
            try:
                conteo = sum(1 for sub in item.iterdir() if sub.is_dir())   # Contamos subdirectorios inmediatos
                resultados.append({'nombre': item.name, 'subs': conteo})
                print(f"[DIRECTORIO] {item.name}: {conteo} subcarpetas")	# Visualización solicitada
            except PermissionError:
                continue 
    crear_csv(resultados, ruta_guardado)	 								# Llamada a funciones secundarias
    generar_sintesis(resultados)

# --- LLAMADA A LA FUNCIÓN ---

if __name__ == "__main__":
    # Define aquí tus rutas
    ruta_objetivo = r""      		# Ruta a explorar
    ruta_donde_archivar = r""       # Ruta para guardar el CSV
    
    procesar_ruta(ruta_objetivo, ruta_donde_archivar)


Este script lee un conjunto de directorios y genera un listado CSV en el que se recoge el número de subdirectorios que contiene cada uno. Para ello utiliza dos funciones secundarias y una principal. La funcionalidad de cada una de ellas queda explicada en los comentarios del script.

Este otro script añade al anterior el conteo de todos los archivos que contie el directorio.


  
from pathlib import Path
import csv
from collections import Counter

#Función secundaria. Guarda el listado detallado en un archivo CSV.

def crear_csv(datos, ruta_destino):
    nombre_archivo = Path(ruta_destino) / "listado_completo_directorios.csv"
    try:
        with open(nombre_archivo, mode='w', newline='', encoding='utf-8') as f:
            escritor = csv.writer(f)
            # Añadimos la nueva columna de archivos totales
            escritor.writerow(["Directorio", "Num_Subcarpetas_Directas", "Num_Archivos_Totales"])
            for fila in datos:
                escritor.writerow([fila['nombre'], fila['subs'], fila['archivos']])
        print(f"\n[SISTEMA] Archivo guardado en: {nombre_archivo}")
    except Exception as e:
        print(f"\n[ERROR] No se pudo crear el CSV: {e}")

#Función secundaria. Calcula y muestra la frecuencia de subdirectorios.

def generar_sintesis(datos):
    total_directorios = len(datos)
    if total_directorios == 0:
        return
    frecuencias = Counter(d['subs'] for d in datos)
    print("\n" + "="*50)
    print("--- RESUMEN ESTADÍSTICO DE FRECUENCIAS ---")
    for num_subs, cantidad in sorted(frecuencias.items()):
        porcentaje = (cantidad / total_directorios) * 100
        print(f"CARPETAS con {num_subs} subdirectorios : {cantidad} carpetas -- {porcentaje:.2f}% sobre total")
    print("="*50)

#Función principal. oordina el análisis profundo de la ruta.

def procesar_ruta(ruta_analizar, ruta_guardado):
    path_base = Path(ruta_analizar)
    if not path_base.exists() or not path_base.is_dir():
        print(f"Error: '{ruta_analizar}' no es una ruta válida.")
        return
    resultados = []
    print(f"\nAnalizando contenido en: {path_base.resolve()}\n")
    print(f"{'DIRECTORIO':<25} | {'SUBS':<6} | {'ARCHIVOS (TOTAL)'}")
    print("-" * 60)
    
    for item in path_base.iterdir():
        if item.is_dir():
            try:
                conteo_subs = sum(1 for sub in item.iterdir() if sub.is_dir())	 # 1. Contar subdirectorios directos (primer nivel)
                conteo_archivos = sum(1 for f in item.rglob('*') if f.is_file()) # 2. Contar TODOS los archivos rglob('*')
                resultados.append({
                    'nombre': item.name, 
                    'subs': conteo_subs, 
                    'archivos': conteo_archivos
                })
                print(f"[DIRECTORIO] {item.name:<12}: {conteo_subs:>3} subs | {conteo_archivos:>5} archivos")	# Visualización por CMD 
            except PermissionError:
                print(f"[!] Sin permiso para acceder a: {item.name}")
                continue
    crear_csv(resultados, ruta_guardado)	    								# Ejecución de reportes
    generar_sintesis(resultados)

# --- SECCIÓN DE LLAMADA A LA FUNCIÓN ---

if __name__ == "__main__":
    # Ajusta estas rutas según tu necesidad
    ruta_objetivo = r""      					# Ruta a explorar
    ruta_donde_archivar = r""                   # Ruta para guardar el CSV
    
    procesar_ruta(ruta_objetivo, ruta_donde_archivar)


Finalmente, en este script cambiamos el enfoque y en vez de trabajar con colecciones de directorios lo vamos a hacer con un directorio en concreto, pero incrementando el detalle del estudio de su estructura (subdirectorios) y de su contenido (archivos), generando un informe útil para el estudio estructural del expediente.



from pathlib import Path
import csv
from collections import Counter

#Función secundaria. Guarda el desglose detallado en un archivo CSV.

def crear_csv(datos, ruta_destino):
    nombre_archivo = Path(ruta_destino) / "estructura_analizada.csv"
    try:
        with open(nombre_archivo, mode='w', newline='', encoding='utf-8') as f:
            escritor = csv.writer(f)
            escritor.writerow([
                "Entrada", 
                "Subcarpetas_Directas", 
                "Archivos_Raiz", 
                "Archivos_en_Subdirs", 
                "Total_Archivos"
            ])
            for fila in datos:
                escritor.writerow([
                    fila['nombre'], 
                    fila['subs'], 
                    fila['archivos_raiz'], 
                    fila['archivos_sub'], 
                    fila['total_archivos']
                ])
        print(f"\n[SISTEMA] Reporte CSV generado en: {nombre_archivo}")
    except Exception as e:
        print(f"\n[ERROR] No se pudo crear el CSV: {e}")

# Función secundaria. Muestra la estadística de frecuencias por consola.

def generar_sintesis(datos):
    total_entradas = len(datos)
    if total_entradas == 0:
        return
    frecuencias = Counter(d['subs'] for d in datos
    print("\n" + "="*85)
    print("--- RESUMEN ESTADÍSTICO DE FRECUENCIAS (Basado en Subdirectorios Directos) ---")
    for num_subs, cantidad in sorted(frecuencias.items()):
        porcentaje = (cantidad / total_entradas) * 100
        print(f"ENTRADAS con {num_subs} subdirectorios directos: {cantidad} unidades -- {porcentaje:.2f}%")
    print("="*85)

# Función principal. Analiza la ruta e incluye el conteo del directorio raíz

def procesar_estructura_principal(ruta_objetivo):
    path_base = Path(ruta_objetivo)
    if not path_base.exists() or not path_base.is_dir():
        print(f"\n[ERROR] La ruta '{ruta_objetivo}' no es válida.")
        return
    resultados = []
    print(f"\nExplorando: {path_base.resolve()}")
    header = f"{'ENTRADA/CARPETA':<25} | {'SUBS':<5} | {'RAIZ':<7} | {'EN SUBS':<8} | {'TOTAL'}"
    print(header)
    print("-" * len(header))
    
    # --- PARTE 1: Analizar la propia carpeta raíz introducida ---
    try:
        subs_raiz = sum(1 for x in path_base.iterdir() if x.is_dir())
        archivos_totales_raiz = sum(1 for x in path_base.rglob('*') if x.is_file())
        archivos_directos_raiz = sum(1 for x in path_base.iterdir() if x.is_file())
        resultados.append({						 # Añadimos la entrada de la carpeta principal
            'nombre': f"RAIZ ({path_base.name})",
            'subs': subs_raiz,
            'archivos_raiz': archivos_directos_raiz,
            'archivos_sub': archivos_totales_raiz - archivos_directos_raiz,
            'total_archivos': archivos_totales_raiz
        })
        print(f"{'-- DIRECTORIO RAIZ --':<25} | {subs_raiz:>5} | {archivos_directos_raiz:>7} | {archivos_totales_raiz - archivos_directos_raiz:>8} | {archivos_totales_raiz:>5}")
    except PermissionError:
        print(f"{'RAIZ':<25} | [!] Acceso denegado")

    # --- PARTE 2: Analizar los subdirectorios internos (si los hay) ---
    for item in path_base.iterdir():
        if item.is_dir():
            try:
                conteo_subs = sum(1 for x in item.iterdir() if x.is_dir())
                total_archivos = sum(1 for x in item.rglob('*') if x.is_file())
                if conteo_subs == 0:
                    archivos_raiz = total_archivos
                    archivos_en_subdirs = 0
                else:
                    archivos_raiz = sum(1 for x in item.iterdir() if x.is_file())
                    archivos_en_subdirs = total_archivos - archivos_raiz
                resultados.append({
                    'nombre': item.name, 
                    'subs': conteo_subs, 
                    'archivos_raiz': archivos_raiz,
                    'archivos_sub': archivos_en_subdirs,
                    'total_archivos': total_archivos
                })  
                print(f"{item.name[:25]:<25} | {conteo_subs:>5} | {archivos_raiz:>7} | {archivos_en_subdirs:>8} | {total_archivos:>5}")
            except PermissionError:
                continue
    if resultados:
        crear_csv(resultados, path_base)
        generar_sintesis(resultados)

# --- SEGMENTO DE LLAMADA A LA FUNCIÓN ---

if __name__ == "__main__":
    ruta_input = input("Introduce la ruta del directorio a analizar: ").strip().replace('"', '')
    procesar_estructura_principal(ruta_input)


lunes, 4 de mayo de 2026

DATOS. Limpieza de datos

Tablas complejas (.odt) (VI)

Optención automátizada de todos los datos de las tablas del documento

Con esta entrada finalizo la presentación de script de recuperación de datos semi-estructurados de tablas-formulario soportadas sobre archivos .odt. En este caso vamos a generar un procedimiento cuyo objetivo es obtener todos los datos de un conjunto de tablas del mismo documento, en teoría de todos los datos de todas las tablas, aunque en la práctica no es necesario y la mayoría de las veces puede que tampoco conveniente, abarcarlas todas.

Puede considerarse que este script es complementario del expuesto en la entrada anterior, y lo es en cuanto que ahora lo que pretendemos es obtener todos los datos de un único documento, mientras que antes lo que pretendíamos era obtener los mismos datos de muchos documentos diferentes. La cuestión es que este cambio de objetivo tiene varias implicaciones: en lugar de dirigir nuestros esfuerzos al estudio de una realida concreta y su manifestación colectiva en documentos individuales; ahora pretendemos recopilar exhaustivamete los datos de un único documento, aunque estos documentos comparten contener tablas-formulario y no tablas de datos en sentido estricto. De ahí que estemos hablando de datos semi-estructurados, frente a los datos estructurados que presentan las grandes (aunque no necesariamente, sí con frecuencia) bases de datos, como las que se manejan en la minería de datos y en la IA.

Una diferencia fácilmente observable que deriva del nuevo planteamiento es que antes el objetivo era obtener un archivo CSV por cada tabla que se analiza en la que cada registro (fila) es el contenido de dicha tabla en un documento; ahora vamos a generar un archivo .xlsx que contiene varias hojas, una por cada tabla del documento .odt. La elección de este soporte está justificada por nuestro interés por mantener todos los datos en un único archivo, y no tenerlos dispersos en varios, como obligaría el uso del formato csv. Esto facilita el manejo de los archivos, su consulta y posterior manipulación desde el servicio excel o Calc.



# --- 0. BIBLIOTECAS ---
import os
import pandas as pd
from odf.opendocument import load
from odf.table import Table, TableRow, TableCell
from odf import teletype

# --- 1. MOTOR DE LIMPIEZA ---
def limpiar_texto_odt(texto_bruto):
    if not texto_bruto: return ""
    texto = texto_bruto.replace('\n', ' ').replace('\r', ' ').replace('\t', ' ')
    return " ".join(texto.split()).lower()

# --- 2. TRADUCTOR DE COORDENADAS ---
def obtener_referencia_celda(fila_idx, col_idx):
    letras = ""
    temp_col = col_idx
    while temp_col >= 0:
        letras = chr(65 + (temp_col % 26)) + letras
        temp_col = (temp_col // 26) - 1
    return f"{letras}{fila_idx + 1}"

# --- 3. ANALIZADOR DE ESTRUCTURA (MEJORADO) ---
def extraer_datos_tabla(tabla):
    filas_xml = tabla.getElementsByType(TableRow)
    if not filas_xml: return [], [], "0x0"

    max_cols = 0
    for celda in filas_xml[0].getElementsByType(TableCell):
        span = int(celda.getAttribute("numbercolumnsspanned") or 1)
        max_cols += span
    
    total_filas = len(filas_xml)
    # MODIFICACIÓN (1): Generar el dato de estructura
    tipo_tabla = f"{total_filas}x{max_cols}"
    
    ocupada = [[False for _ in range(max_cols)] for _ in range(total_filas)]
    ids, contenidos = [], []

    for r_idx, fila in enumerate(filas_xml):
        celdas_xml = fila.getElementsByType(TableCell)
        c_xml_cursor = 0 
        for c_idx in range(max_cols):
            if ocupada[r_idx][c_idx]: continue
            if c_xml_cursor < len(celdas_xml):
                celda_actual = celdas_xml[c_xml_cursor]
                c_span = int(celda_actual.getAttribute("numbercolumnsspanned") or 1)
                r_span = int(celda_actual.getAttribute("numberrowsspanned") or 1)
                
                texto = teletype.extractText(celda_actual).strip()
                ids.append(obtener_referencia_celda(r_idx, c_idx))
                contenidos.append(texto)
                
                for i in range(r_span):
                    for j in range(c_span):
                        if r_idx + i < total_filas and c_idx + j < max_cols:
                            ocupada[r_idx + i][c_idx + j] = True
                c_xml_cursor += 1
    return ids, contenidos, tipo_tabla

# --- 4. FUNCIÓN MAESTRA EVOLUCIONADA ---
def ejecutar_extraccion_a_excel(ruta_odt, mapa_claves):
    if not os.path.exists(ruta_odt):
        print(f"[!] El archivo no existe en: {ruta_odt}"); return

    directorio_salida = r"C:\PROCESAMIENTO_DATOS_SEO\TABLAS_DOC"
    if not os.path.exists(directorio_salida):
        os.makedirs(directorio_salida, exist_ok=True)

    doc = load(ruta_odt)
    tablas = doc.body.getElementsByType(Table)
    
    nombre_base = os.path.splitext(os.path.basename(ruta_odt))[0]
    ruta_excel = os.path.join(directorio_salida, f"{nombre_base}_EXTRAIDO.xlsx")
    
    # Control de duplicados por clave normalizada
    claves_encontradas = {limpiar_texto_odt(item['termino']): False for item in mapa_claves}
    
    with pd.ExcelWriter(ruta_excel, engine='openpyxl') as writer:
        for t in tablas:
            filas = t.getElementsByType(TableRow)
            if not filas: continue
            celdas_xml = filas[0].getElementsByType(TableCell)
            if not celdas_xml: continue
            
            # MODIFICACIÓN (2): Comprobar contra el mapa de claves y su posición (0 o 1)
            for configuracion in mapa_claves:
                termino_original = configuracion['termino']
                posicion_busqueda = configuracion['posicion'] # 0 o 1
                termino_norm = limpiar_texto_odt(termino_original)
                
                # Seguridad: verificar que la tabla tenga la celda solicitada
                if len(celdas_xml) <= posicion_busqueda: continue
                
                valor_celda_ancla = limpiar_texto_odt(teletype.extractText(celdas_xml[posicion_busqueda]))
                
                if termino_norm in valor_celda_ancla and not claves_encontradas[termino_norm]:
                    ids, valores, tipo_tabla = extraer_datos_tabla(t)
                    
                    # Insertar tipo_tabla como primer campo (Punto 1)
                    final_ids = ["tipo_tabla"] + ids
                    final_valores = [tipo_tabla] + valores
                    
                    df = pd.DataFrame([final_valores], columns=final_ids)
                    
                    nombre_hoja = termino_original[:30]
                    df.to_excel(writer, sheet_name=nombre_hoja, index=False)
                    
                    claves_encontradas[termino_norm] = True
                    print(f"[OK] Tabla '{termino_original}' ({tipo_tabla}) guardada.")
                    break 

    print(f"\nPROCESO COMPLETADO. Ruta: {ruta_excel}")

# --- Llamada a la función principal ---

if __name__ == "__main__":

    ARCHIVO_TRABAJO = r""						#Introduce aquí la ruta absoluta del documento a procesar 
    
    MAPA_CLAVES = [								# Lista de diccionarios con término y posición (0=A1, 1=B1). Adáptala a tus necesidades
        {'termino': 'fecha', 'posicion': 0},
        {'termino': 'nie', 'posicion': 0},
        {'termino': 'escolarizado', 'posicion': 1},
        {'termino': 'admisión', 'posicion': 0},
        {'termino': 'padre', 'posicion': 0},
        {'termino': 'anteriores', 'posicion': 0},
        {'termino': 'nueva', 'posicion': 1},
        {'termino': 'discapacidad', 'posicion': 0},
        {'termino': 'ordinario', 'posicion': 1},
        {'termino': 'tipo', 'posicion': 0},
        {'termino': 'personales', 'posicion': 0}
    ]
    
    ejecutar_extraccion_a_excel(ARCHIVO_TRABAJO, MAPA_CLAVES)


Obsérvese que este script comparte la mayor parte de la estructura y la lógica con el precedente, pero presenta una diferencia de mucha importancia para el funcionamiento del script: el uso de la lista de diccionarios MAPA_CLAVES = [] que contiene la colección de términos de referencia para identificar la tabla y el código que establece la celda en la que buscar el término. La lista que aquí se presenta debe ser adaptada a las necesidades concretas que derivan del documento a procesar.

A pesar de que ahora obtenemos un .xlsx, tampoco obtenemos unas tablas que se puedan manejar directamente, siendo necesario procesarlas desde el servicio de hoja de cálculo que se desee o mediante código, aunque presentan una formulación compatible con lo que consideramos datos estructurados, lo que no impide que sea necesario organizarlos y proceder a su limpieza, si se da el caso.

Por último debo repetir aqui lo que dije al presentar el script complementario visto en su momento respecto al uso de la IA y a sus condiciones y limitaciones.

DATOS. Limpieza de datos

Tablas complejas (.odt) (V)

Volcado masivo de datos de una tabla

Además de acceder a una celda concreta, podemos acceder también al conjunto de los datos de una tabla determinada, sea de un documento en concreto o de una collección de documentos.

Sabemos que es posible acceder a una tabla por su identificador en el navegador del procesador de texto, pero este procedimiento tiene una limitación y es que en la colección de documento, todos ellos deben tener la misma estructura de tablas, y la misma denominación, al menos para la tabla que nos interesa, lo que viene a implicar, normalmente, que todos comparten el mismo orden de las tablas. El problema es que todas estas coincidencias no son frecuentes ni fáciles de observar, lo que limita y complica el procedimiento de estracción de datos.

Para sortear estas limitaciones y ampliar el funcionalidad real del script, facilitando la obtención masiva de datos de documentos que pueden no compartir aspectos formales, pero que sí comparten contenidos (tal es el caso de los diferentes modelos de, por ejemplo, informe de evalauación psicopedagógica), una opción es identificar previamente a la extracción de datos y como condición para ella, esa tabla mediante la identificación de, por ejemplo, un concepto que de identidad a la tabla como conjunto.

Para que se entienda mejor lo que quiero decir y sus implicaciones, vamos a valorar su incidencia en un ejemplo real: la obtención de los datos de la que podría ser la Tabla1 que podría identificar el navegador.

Lo que muestra esta imagen es la tabla situada visualmente en primer lugar tanto en uno de los modelos de dictamen y otro de informe. Aunque se trata de modelos concretos, otros comparten la presencia de esta tabla en términos de estructura y de contenido, aunque se puedan observar algunas diferencias que generan (a posteriori) ciertos problemas de compatibilidad. Lo ya desde el principio no comparten es el identificador de la tabla según muestra el navegador, incluyendo que como tales no son necesariamente la Tabla1. Esto complica la viabilidad de la estrategia empleada en el script de la entrada anterior.

Cierto que podríamos utilizar determinadas estrategias para dar cabida a la variedad de identificadores de las tablas en función del documento (tipo y modelo), pero esto exige la revisión detallada de la variedad documental objeto de interés. Pero disponemos de una alternativa que evita esa revisión. La base es la siguiente: si observamos ambas tablas comprobaremos que en su celda A1 (0,0) ambas presentan un texto que es de hecho una etiqueta de la base de datos que es la tabla en su conjunto; ese texto contiene y comparte, en ambos casos, el término 'fecha'. Si, como ya sabemos, recorremos las tablas del documento, accedemos a su celda A1 y a su contenido, y realizamos una identificación en función del texto que nos interesa (en este caso 'fecha') podremos acceder al contenido de cualquiera de las celdas de esta tabla o al conjnuto de ellas. Este es el comentido del script que se muestra a continuación.



# ---0. BIBLIOTECAS NECESARIAS ---

from odf.opendocument import load
from odf.table import Table, TableRow, TableCell
from odf import teletype
import os
import csv
import sys

# --- 1. MOTOR DE LIMPIEZA ---
def limpiar_texto_odt(texto_bruto):
    if not texto_bruto: return ""
    texto = texto_bruto.replace('\n', ' ').replace('\r', ' ').replace('\t', ' ')
    return " ".join(texto.split()).lower()

# --- 2. TRADUCTOR DE COORDENADAS ---
def obtener_referencia_celda(fila_idx, col_idx):
    letras = ""
    temp_col = col_idx
    while temp_col >= 0:
        letras = chr(65 + (temp_col % 26)) + letras
        temp_col = (temp_col // 26) - 1
    return f"{letras}{fila_idx + 1}"

# --- 3. ANALIZADOR DE TABLA ---
def extraer_datos_tabla(tabla):
    filas_xml = tabla.getElementsByType(TableRow)
    if not filas_xml: return [], [], "0x0"

    max_cols = 0
    for celda in filas_xml[0].getElementsByType(TableCell):
        span = int(celda.getAttribute("numbercolumnsspanned") or 1)
        max_cols += span
    
    total_filas = len(filas_xml)
    dato_estructura = f"{total_filas}x{max_cols}"
    
    ocupada = [[False for _ in range(max_cols)] for _ in range(total_filas)]
    ids, contenidos = [], []

    for r_idx, fila in enumerate(filas_xml):
        celdas_xml = fila.getElementsByType(TableCell)
        c_xml_cursor = 0 
        for c_idx in range(max_cols):
            if ocupada[r_idx][c_idx]: continue
            if c_xml_cursor < len(celdas_xml):
                celda_actual = celdas_xml[c_xml_cursor]
                c_span = int(celda_actual.getAttribute("numbercolumnsspanned") or 1)
                r_span = int(celda_actual.getAttribute("numberrowsspanned") or 1)
                
                texto = limpiar_texto_odt(teletype.extractText(celda_actual))
                ids.append(obtener_referencia_celda(r_idx, c_idx))
                contenidos.append(texto)
                
                for i in range(r_span):
                    for j in range(c_span):
                        if r_idx + i < total_filas and c_idx + j < max_cols:
                            ocupada[r_idx + i][c_idx + j] = True
                c_xml_cursor += 1
    return ids, contenidos, dato_estructura

# --- 4. FUNCIÓN PRINCIPAL ---
def procesar_coleccion(directorio_origen, termino_busqueda, col_ancla_idx=0):
    ruta_salida_base = r"" 						# Aquí tu ruta del archivo de salida
    if not os.path.exists(ruta_salida_base):
        os.makedirs(ruta_salida_base)

    csv_maestro = os.path.join(ruta_salida_base, "") #Aquí (entre comillas) el nombre de la tabla CSV de salida
    datos_totales = []
    encabezados_globales = None
    termino = limpiar_texto_odt(termino_busqueda)

    if not os.path.exists(directorio_origen):
        print(f"[ERROR] No existe la carpeta: {directorio_origen}", flush=True)
        return

    archivos = [f for f in os.listdir(directorio_origen) if f.endswith('.odt')]
    
    print(f"\n>>> INICIO: Detectados {len(archivos)} archivos.", flush=True)	 # flush=True fuerza a mostrar el texto

    for nombre_archivo in archivos:
        ruta_completa = os.path.join(directorio_origen, nombre_archivo)
        try:
            doc = load(ruta_completa)
            tablas = doc.body.getElementsByType(Table)
            encontrado = False
            
            for t in tablas:
                filas = t.getElementsByType(TableRow)
                if not filas: continue
                celdas = filas[0].getElementsByType(TableCell)
                
                if len(celdas) <= col_ancla_idx: continue
                
                valor_ancla = limpiar_texto_odt(teletype.extractText(celdas[col_ancla_idx]))
                
                if termino in valor_ancla:
                    ids, valores, estructura = extraer_datos_tabla(t)
                    if encabezados_globales is None:
                        encabezados_globales = ["ARCHIVO_ORIGEN", "ESTRUCTURA"] + ids
                    
                    datos_totales.append([nombre_archivo, estructura] + valores)
                    print(f" [OK] {nombre_archivo} | Estructura: {estructura}", flush=True)
                    encontrado = True
                    break
        except Exception as e:
            print(f" [ERR] Error en {nombre_archivo}: {e}", flush=True)

    if datos_totales:
        with open(csv_maestro, mode='a', newline='', encoding='utf-8-sig') as f:
            escritor = csv.writer(f)
            escritor.writerow(encabezados_globales)
            escritor.writerows(datos_totales)
        print(f"\n>>> ÉXITO: CSV generado en {csv_maestro}", flush=True)
    else:
        print("\n>>> AVISO: No se encontraron coincidencias.", flush=True)

# --- Llamada a la función ---

if __name__ == "__main__":
    CARPETA = r""		# Aquí el directorio de los archivos a trabajar
    TERMINO = ""		# Aquí el término de identificación
    # 0 = A1, 1 = B1	# Identificadores de celda de referencia
    procesar_coleccion(CARPETA, TERMINO, 0)


Este script es suficientemente complejo como para necesitar una explicación con cierto detalle. Tenemos un script que desarrolla un sistema automatizado de extracción de datos jerarquizado que utiliza la biblioteca odfpy. Consta o requiere unas funciones que identificamos como secundarias, una función principal y el código de llamada a la función.

Las funciones secundarias realizan tareas de limpieza def limpiar_texto_odt(texto_bruto):, cálculo de coordenadas para la identificación de las celdas def obtener_referencia_celda(fila_idx, col_idx): y para la reconstrucción de celdas combinadas def extraer_datos_tabla(tabla):.

def procesar_coleccion(directorio_origen, termino_busqueda, col_ancla_idx=0): es la función principal, la que gestiona el flujo de archivos, la búsqueda del término identificador y el almacenamiento de los datos como CVS. Esta función es la que llama a las funciones secundarias y es llamada, a su vez, por el script principal.

Este script contiene la identificación de variables (ruta de archivos, término de búsqueda y celda de referencia para la búsqueda) y la llamada a la función principal. Sobre la doble opción de celda, decir que se ha implementado para evitar la dificultad que presentan aquellas tablas cuya celda A1 puede resultar inapropiada por diversos motivos, por ejemplo, por estar vacía sin que ello implique algún tipo de irregularidad.

Sin la ayuda de IA-Gemini no habría sido posible desarrollarla, pero incluso con ella me resultó de gran dificultad, dado que fue necesario reformular el procedimiento varias veces, reconstruirlo paso a paso y controlar que el funcionamiento de cada una de las partes y del conjunto se ajustar a lo esperado y se alcanzara objetivo final: obtener un archivo CSV que contenga el texto de cada una de las celdas de la tabla de dos conjuntos de documentos que comparten (como se aprecia en la imagen superior) la estructura y el contenido de esa tabla identificada por el string 'fecha'.

No se prevé, no obstante, que ese documento resultante sea de utilidad inmediata, ya que se precisa cierto tratamiento que puede ejecutarse, eso sí, manualmente sobre el servico de hoja de cálculo o mediante código. Pero este es un proceso que deriva a una segunda fase del tratamiento de datos: el de la organización y limpieza de datos obtenidos mediante procedimientos de extracción.

DATOS. Limpieza de datos

Tablas complejas (.odt) (IV)

Acceso al contenido de celdas

En la entrada anterior conseguimos acceder a una tabla concreta y a una celda determinada. Aprovechando estas funcionalidades, en ésta el objetivo es convertir ese conociento en una utilidad de interés: la obtención de un dato determinado (podrían ser varios) de un grupo de documentos iguales (comparten tipología .odt, tabla y estructura de la tabla).

Este script es el primero de una serie de tres que tratarán sobre la obtención masiva y automatizada de contenido de las tablas de los documentos .odt.



# --- 0. BIBLIOTECAS ---
import os
import csv
from odf import opendocument, table, teletype

# --- 1. CONFIGURACIÓN ---
ruta_directorio_fuente = r"mi_directorio" 			# Carpeta con los .odt
nombre_tabla_objetivo = "mi_tabla"		  			# Tabla elegida
fila_destino = 1						  			# Referencias de la celda
columna_destino = 1

directorio_destino = r"directorio_de_escritura_csv"	# Configuración de salida (Rutas solicitadas)
nombre_archivo_csv = "extraccion_tabla_celda.csv"	# Configuración del nombre del CSV (e. extraccion_tabla4_A1)
ruta_completa_csv = os.path.join(directorio_destino, nombre_archivo_csv)

# --- 2. PREPARACIÓN DEL ENTORNO ---
if not os.path.exists(directorio_destino):		# Crear el directorio de destino si no existe
    os.makedirs(directorio_destino)
    print(f"Directorio creado: {directorio_destino}")

datos_acumulados = []		# Lista para almacenar los resultados de la consulta

# --- 3. PROCESAMIENTO DE ARCHIVOS ---
if not os.path.exists(ruta_directorio_fuente):
    print("Error: El directorio fuente no existe.")
    exit()

archivos = [f for f in os.listdir(ruta_directorio_fuente) if f.endswith(".odt")]

print(f"--- Procesando {len(archivos)} archivos ---")

for archivo in archivos:
    ruta_archivo = os.path.join(ruta_directorio_fuente, archivo)
    
    try:
        doc = opendocument.load(ruta_archivo)
        tablas = doc.getElementsByType(table.Table)
        tabla_encontrada = None

        for t in tablas:
            if t.getAttribute("name") == nombre_tabla_objetivo:
                tabla_encontrada = t
                break

        if tabla_encontrada:
            filas = tabla_encontrada.getElementsByType(table.TableRow)
            if fila_destino < len(filas):
                celdas = filas[fila_destino].getElementsByType(table.TableCell)
                if columna_destino < len(celdas):
                    contenido = teletype.extractText(celdas[columna_destino])
                    datos_acumulados.append([archivo, contenido])
                    print(f"[OK] {archivo} -> Extraído")
                else:
                    print(f"[Error] {archivo} -> Columna {columna_destino} no existe.")
            else:
                print(f"[Error] {archivo} -> Fila {fila_destino} no existe.")
        else:
            print(f"[Error] {archivo} -> Tabla '{nombre_tabla_objetivo}' no encontrada.")

    except Exception as e:
        print(f"[Error] {archivo} -> No se pudo procesar: {e}")

# --- 4. GENERACIÓN DEL ARCHIVO CSV ---
if datos_acumulados:
    try:
        with open(ruta_completa_csv, mode='a', newline='', encoding='utf-8-sig') as archivo_csv:
            escritor = csv.writer(archivo_csv, delimiter=';') 	# Usamos ';' por compatibilidad con Excel en español
            escritor.writerow(["Nombre del Archivo", "Contenido de la Celda"])	# Escribir cabeceras
            escritor.writerows(datos_acumulados)								# Escribir los datos
        print(f"\n--- PROCESO FINALIZADO ---")
        print(f"Archivo guardado en: {ruta_completa_csv}")
    except Exception as e:
        print(f"\nError al intentar crear el archivo CSV: {e}")
else:
    print("\nNo se encontraron datos para guardar en el CSV.")


Este script requiere el conocimiento previo del documento para ser concretado, dado que es encesario que el directorio contenga documentos que compartan características, estructura y contenidos. De no ser así los datos que se obtengan dificilmente satisfarán el objetivo con el que se creó este script. Este conocimiento se basa, en lo concreto, en el estudio de la estructura de tablas del documento y de las celdas de la tabla elgida según el objetivo del estudio, especialmente de la naturaleza de ese contenido textual.

Además de acceder al documento, a la tabla y a la celda seleccionadas por el usuario, y capturar su contendio, se muestra por pantalla el proceso de extracción de datos y después se genera un archivo CSV (nombre_archivo_csv = "extraccion_tabla_celda.csv") en el directorio que el usuario decida (directorio_destino = r"directorio_de_escritura_csv"). Este archivo CSV se puede manipular mediante código, pero también visualizar y manipular directamente desde un servicio de hoja de cálculo (Excel o Calc).

Aunque aquí sólo ejemplificamos un procedimiento básico (extraer únicamente un dato) el script se puede modificar para la extracción de todos los datos que se consideren necesarios para un estudio concreto, por ejemplo, el estudio de la relación entre dos variables.

DATOS. Limpieza de datos

Tablas complejas (.odt) (III)

Acceso a tablas

Sirva lo visto en la entrada anterior como base para un mejor conocimiento de la estructura de tablas de un documento .odt y ocupémonos en esta de cómo acceder a esa colección y a cada una de esas tablas mediante código Python.

Para manejar documentos .odt desde Python disponemos de varias bibliotecas que ahora únicamente menciono para que puedas conocerlas y estudiarlas con más detalle. Me refiero a:

  1. ODFPY, biblioteca de bajo nivel que permite controlar prácticamente cualquier aspecto de un archivo .odt
  2. EzODF, cuyo objetivo es que crear un documento sea mucho más intuitivo.
  3. Pypandoc, pensada para la conversión de archivos .odt (de Markdown a ODT o de ODT a PDF/HTML).
  4. y finalmente Relatorio, interesante para trabajar con plantillas.

Dado que nosotros nos centramos ahora en la extracción de datos, las bibliotecas que usaremos serán preferentemente las dos primeras, por lo que te sugiero que te centres en ellas en tus investigaciones.

El acceso al contenido de un documento .odt es, en realidad, el acceso a un archivo zip que contiene varios documentos XML, por lo que si conviertes un archivo .odt a .zip y accedes a su contenido podrás localizar, entre otros, un documento llamado content.xml; si lo abres verás todo el código de etiquetas xml entre las que están las que identifican a las tablas y su estructura.

Pero si lo que deseas es acceder a las tablas de forma mucho más funcional puedes aplicar este script al documento que tu elijas, siempre que sea un documento.odt y contega tablas.



from odf import opendocument, table

#Funciones de los módulos de la biblioteca odfpy
'''
1. opendocument. Permite la gestión del archivo como un "contenedor" completo
Cargar y guardar: Permite abrir archivos (opendocument.load()) y salvar los cambios (doc.save())
Crear documentos: Generar un documento nuevo desde cero
Acceso a la estructura: Administrador de todos los elementos del archivo (tablas, párrafos, estilos)

2. table. Trabajo con las hojas de cálculo y las tablas de los documentos de texto
Creación de tablas: Permite definir nuevas tablas (table.Table)
Gestión de filas y celdas: Proporciona los objetos necesarios: table.TableRow para las filas y table.TableCell para las celdas
Atributos de celda: Manejar propiedades como la unión de celdas (spans) y el nombre de la tabla
'''

doc = opendocument.load("mi_documento.odt")		# Cargar archivo

tablas = doc.getElementsByType(table.Table)		# Buscar todas las tablas (etiqueta )

for t in tablas:								# Recorrido y visualización del identificador o nombre de las tablas
    nombre_tabla = t.getAttribute("name")
    print(f"Encontrada: {nombre_tabla}")


Observa que estamos trabajando con la biblioteca ODFPY, concretamente los módulos opendocument, que permite acceder al documento (doc = opendocument.load("mi_documento.odt"), y table, que facilita el acceso a las tablas (tablas = doc.getElementsByType(table.Table)); de las cuales visualizamos su atributo "name" (nombre_tabla = t.getAttribute("name")), que es el mismo que nos permite ver el navegador del documento.

Hasta aquí hemos conseguido acceder al equivalente de visualización del listado de tablas del documento que proporciona la utilidad Navegador del procesador de texto (Writer), lo cual no deja de tener su interés, ya que refuerza la idea de que es útil conocer y manejar ese navegador. Pero la utilidad inmediata puede que aun no se constate, así que vamos a avanzar un poco más en esa línea, accediendo al contenido de una celda concreta de una tabla determinada.

Aquí nos interesa hablar de una cuestión que puede parecer obvia, pero que no deja de tener relevancia en el manejo de estas tablas-formulario: en ellas no existe nada que diferencie lo que en la lógica de la estructura de una tabla de datos (base de datos) se puede considerar una diferenciación fundamental y básica: no es lo mismo una etiqueta o nombre de campo que el campo (contenido del campo) en si mismo. En una tabla-formulario una determinada posición o celda (A1, por ejemplo) simplemente contiene texto (y siempre texto) o no lo contiene, sin importar si ese texto es el dato o es la etiqueta. El conocimiento de esa diferencia, con absoluta seguridad, sólo lo puede aportar la persona que trabaja con el documento.Pueden emplearse determinadas estrategias para inferir si estamos ante uno u otro contenido (ejemplo, si el texto de la celda finaliza en : (dos puntos), es de esperar que se trate de una etiqueta), pero no existe seguridad absoluta en que la inferencia sea viable ni cierta. De aquí quiero derivar la importancia de la implicación directa del programador en el diseño del procedimiento, por encima de la automatización y/o del uso de la IA.

Pero volvamos a retomar el desarrollo de código para acceder al contenido de las tablas, ahora con independencia de la naturaleza de este contenido, el cual es "etiquetado" como etiqueta o como dato por el programador. El código sólo puede facilitar la automatización del acceso, lo cual no es es poco.



from odf import opendocument, table, teletype, text 

#Funciones de los módulos nuevos de la biblioteca odfpy
'''
3. text. Facilita el trabajo con los elementos estructurales del documento
Párrafos (text.P): Crear los bloques de texto que se insertan en celdas o en el cuerpo del documento
Estilos de texto: Definir encabezados (text.H) y aplicar formatos a fragmentos de texto

4. teletype. Utilidad para facilitar el trabajo con los nodos XML
extractText(): Extrae todo el texto plano de un elemento (una celda o una tabla completa), ignorando las etiquetas internas. Necesario para leer contenido
addTextToElement(): Inserta una cadena de texto dentro de un elemento ODF, encargándose de convertir los caracteres originales

'''

# --- 1. CONFIGURACIÓN ---
ruta_archivo = r""                   #Aquí la ruta completa de tu documento
nombre_tabla_objetivo = ""    #Aquí la tabla elegida según nombre de el navegador
fila_destino = 0                     # Fila de interés
columna_destino = 0             # Columna de interés
nuevo_texto = ""                  #Aquí tu nuevo texto

# --- 2. ACCESO ----
doc = opendocument.load(ruta_archivo)   #Acceso al documento

# --- 3. LOCALIZACIÓN E INVENTARIO ---
tablas = doc.getElementsByType(table.Table)
tabla_encontrada = None

print("\n--- Inventario de tablas en el documento ---")

for i, t in enumerate(tablas):
    nombre_actual = t.getAttribute("name")
    if nombre_actual == nombre_tabla_objetivo:      # Verificamos si es la tabla que buscamos para añadir una marca visual
        tabla_encontrada = t
        print(f"  {i+1}. [X] {nombre_actual} <-- ¡TABLA IDENTIFICADA!")  # Marca visual (-->) y mayúsculas para resaltara
    else:
        print(f"  {i+1}. [ ] {nombre_actual}")      # Listamos el resto de tablas con un prefijo normal

print("-------------------------------------------\n")

if not tabla_encontrada:            # Si tras el bucle no se encontró, detenemos el proceso con un aviso
    print(f"Error: No se encontró la tabla '{nombre_tabla_objetivo}' en el listado anterior.")
    exit()                           # Aquí ponemos un exit()

# --- 4. ACCESO Y ESCRITURA CORREGIDA ---
if tabla_encontrada:
    filas = tabla_encontrada.getElementsByType(table.TableRow)
    if fila_destino < len(filas):
        celdas = filas[fila_destino].getElementsByType(table.TableCell)
        if columna_destino < len(celdas):
            celda_objetivo = celdas[columna_destino]
            contenido_viejo = teletype.extractText(celda_objetivo) # LEER: Esto sigue funcionando igual
            print(f"Contenido previo: '{contenido_viejo}'")
            													 	# ESCRIBIR: El procedimiento correcto
            celda_objetivo.childNodes = []                       	# 1. Limpiamos la celda de párrafos u objetos anteriores
            nuevo_parrafo = text.P()                             	# 2. Creamos un elemento párrafo
            teletype.addTextToElement(nuevo_parrafo, nuevo_texto)   # 3. Añadimos el texto al párrafo
            celda_objetivo.addElement(nuevo_parrafo)                # 4. Insertamos el párrafo dentro de la celda
            
# --- 5. GUARDADO ---
            doc.save(ruta_archivo)
            print(f"Éxito: Se ha escrito '{nuevo_texto}' en la celda.")
        else:
            print("Error: Columna no encontrada.")
    else:
        print("Error: Fila no encontrada.")
else:
    print(f"No se encontró la tabla '{nombre_tabla_objetivo}'.")


Este script, además de acceder y visualizar a las tablas, identifica y se centra en una de ellas (nombre_tabla_objetivo = "") que queda identificada en el listado (if nombre_actual == nombre_tabla_objetivo:). Sobre ella realizamos dos acciones:

  1. Capturar el texto que contiene la celda
  2. Escribir en ella, aunque de forma indirecta, pasando antes por el objeto párrafo

Sobre esta base es posible escribir contenido en las tablas, pero también (y es lo que nos interesa ahora) obtener el escrito en documentos trabajados manualmente. De ahí podemos derivar procedimientos de acceso a datos específicos, habiendo previamente dlimitado los documentos a analizar, la tabla según se muestra en el navegador y la celda identificada según vimos en la entrada correspondiente. Todo esto nos permite construir una utilidad de obtención de datos concretos de un documento o de una colección de documentos iguales.

domingo, 3 de mayo de 2026

DATOS. Limpieza de datos

Tablas complejas (.odt) (II)

Acceso a tablas desde el navegador

Para empezar creo necesario plantea algunas cuestiones respecto a cómo se concreta la implementación de este tipo de tabla-formulario en la mayoría de los documentos prescriptivos y/o de comunicación entre servicios. Y lo hago a sabiendas que, en gran parte, es un brindis al Sol: si no hemos sido capaces (aun) de generalizar la cumplimentación de tablas mediante procedimientos basados en la lógica de Combinar correspondencia, a pesar de tener disponible esta funcionalidad desde hace tanto tiempo, no parece razonable esperar que en el diseño de los documentos se tenga en cuenta lo que implica un procedimiento posterior e hipotético de acceso automatización del acceso a los datos que contienen las tablas de dichos documentos. Y sin embargo sería de gran utilidad hacerlo y no supone mayor complicación.

Tomemos el ejemplo de las tablas mostradas en la entrada precedente...

Lo que vemos es una captura de pantalla de parte de la página 1 del actual modelo de informe de la consejería de Educación de Asturias, el cual aporta como principal novedad un incremento considerable de la estructura tabular y el incremento también significativo de su plantemamiento como formulario, incluyendo el uso de controles de formulario, aunque no en estas tablas. No voy a entrar aquí en lo que este cambio implica en términos de práctica de los SEO ni para la conceptualización de la evaluación psicopedagógica (no es el momento), pero sí en lo que afecta a la cuestión que sí se trata en esta entrada: el desarrollo de un procedimiento de automatización tanto de la cumplimentación del documento como de la estracción de los datos que potencialmente podrían albergar estas tablas.

Al respecto aprecio que esta nueva formulación acentúa la conceptualización del modelo de informe como un soporte para ser usado directamente, efectivamente a modo de formulario; un formulario extenso y complejo, pero un formulario a fin de cuentas. Y hace ya mucho tiempo que este plantemiento está obsoleto, habiendo quedado demostrado largamente que, lejos de facilitar el trabajo a los profesionales, se lo complica aun más. Conisdero cierto esto desde la perspectiva de la creación del informe, pero también (añado ahora) pensando en un futuro en el que se plantee procesar estos documentos como fuente de información pero, dadas las circunstancias que la propia práctica actual refuerza, nos encontremos con que no existe una fuente de datos normalizada, la misma que debería haber servido en su momento para automatizar la creación del documento y que ahora podría servir como fuente de datos, estructurada y accesible.

Ya en otras ocasiones, como motivo del análisis de otros modelos de informe, comenté la conveniencia de mejorar la formualción estructural de las tablas para facilitar su uso manual y la propia automatización. Es evidente que no es la vía por la que ha optado la Administración; tampoco evidentemente por impulsar la automatización ni de la composición de los informes, ni del acceso a sus datos como fuente de información relevante para el conocimiento de la práctica de los SEO. Y eso que debería estar interesada en ello, más aun en tiempos en los que tanto se habla del interés que tiene el análisis de datos como recurso para la mejora de los servicios.

Aun a sabiendas de que lo que diga sea, como ya dije al principio de esta entrada, un brindis al Sol, vuelvo a plantear algunas propuestas pensando en esas posibles mejoras de manejo documental en la composición, pero también, ahora, en facilitar el acceso futuro a los datos.

Y planteo como cuestión de interés la recomendación de que conocer y manejar la información que aporta el navegador del documento; el cual, por permanecer normalmente oculto, no suele ser ni conocido ni, en consecuencia, consultado, aunque la información que aporta es de gran utilidad: entre otras cosas nos ayuda a identificar los componentes estructurales del documento, incluyendo las tablas, a trasladarnos de uno a otro y a conocer sus identificadores. En Writer ofrece esta apariencia...

... y es suficiente con hacer click derecho sobre la cruz (+) de Tablas, o posicionarse en una de ellas en el documento, para que se despliegue el listado de todas las que contiene el documento, lo que facilita información cuantitativa, ordinal y nominal sobre todas ellas, además del desplazamiento por cada una de ellas.

Un poco más oculto aun, en la parte inferior derecha de la pantalla, casi al borde de la zona de trabajo, se ubica un elemento informativo de gran utilidad para el conocimiento y estudio de la estructura de las tablas, conocimiento que es necesario (como veremos) para el desarrollo de procedimientos de acceso a tablas y a datos. Me refiero a la información que devuelve en esa zona el posicionamiento del cursor en el interior de una tabla. Como podemos ver en la imagen que sigue, obtenemos información sobre la tabla: su identificador (conforme al navegador) y celda en coordenadas cartesianas compatible con el formato de hoja de cálculo. En este ejemplo, subrayado en amarillo, nos indica que el cursor se ubica actualmente en la celda B2 de la identificada como Tabla3.

Además de para conocer mejor el servicio Writer, todo lo anteriormente ducho es útil para el manejo del documento, aunque mostrará aun más su utilidad en los procedimientos de automatización que iremos explicando en estas entradas.

DATOS. Limpieza de datos

Tablas complejas (.odt) (I)

Tablas-formulario y su contexto

Lo visto y trabajado en las entradas anteriores responde (y lo hace correctamente) a las situaciones más simples y favorables; aquellas en las que, con independencia de la cantidad de datos que contenga/n la/s tabla/s del .docx, su estructura es asimilable a la de los datos estructurados y el reto para la automatización se limita a identificar la tabla como tal, recorrerla y obtener su contenido. En estas labores y condiciones los recursos empleados han permitido dearrollar con éxito la automatización de la obtención de datos y su conversión a formatos de datos estructurados (por ejemplo, como .xlsx). Pero si en vez de trabajar con ese tipo de tablas tenemos que hacerlo con tablas como la que se muestra abajo, la cosa cambia radicalmente.

Es este un extracto del modelo de informe psicopedagógico, sólo de una pequeña parte de dicho documento, pero mucho más que suficiente para comprobar lo alejados que estamos en este caso de la sencillez de las fuentes de datos con las que hemos trabajado antes. En realidad es dudoso que ahora podemos hablar de tablas en sentido estricto, ya que lo que aquí mostramos tiene más de formulario (sobre tabla) que de tabla en sentido estricto.

Lo cierto es que el abordaje de este tipo de tabla-formulario suele hacerse en sentido inverso al que aquí se plantea: el reto suele consistir en automatizar la cumplimentación de estas "tablas" con un conjunto ya estrucurturado de datos a fin de automatizar parte de la composición del documento; eso que podemos llamar automatizar la composición de la carátula del informe (ver en este enlace cuestiones relativas a temática). Pero ahora nos estamos planteando automatizar la obtención de los datos contenidos en documentos creados mediante servicios de procesamiento de texto (.docx, .odt, por ejemplo) de este nivel de complejidad formal y estructural (eso que hemos llamado antes tabla-formulario) como recurso para la obtención de datos, a su vez, como parte de un proyectos de análisis de datos.

Veamos brevemente algunas diferencias entre estas tablas-formulario y una tabla sencilla:

  • La disposición puede obedecer a la disposición fila-columna, pero la más frecuente obedece a la secuencia etiqueta->contenido
  • Esta secuencia (etiqueta->contenido) se presenta en sucesiones lineales variadas, una vez o varias veces por linea de texto; aunque en todas se observa la distribución vertical de la estructura (disposición de bloques etiqueta->contenido, por oposición a la horizontal de la tabla simple: etiqueta - etiqueta - etiqueta ... |contenido - contenido - contenido...
  • En una misma tabla (visualmente identificada como una unidad y localizable como tal desde el navegador del procesador de texto) se pueden mezclar diferentes presentaciones, además de fusiones de celdas y otras modificaciones respecto a la configuración dominante en la tabla.
  • Además de las estrucuras anteriores y sus contenidos básicos de interés, las tablas pueden tener otros contenidos, como encabezados, subencabezados (para diferenciar secciones), textos explicativos...
  • En ocasiones se pueden emplear controles de formulario, acentuando aun más la configuración de la tabla como formulario.
  • En ocasiones también, en celdas de amplia extensión (toda la línea e incluso resultante de la fusión de celdas) se puede ubicar dentro de la estructura tabular un volumen importante de información en forma de contenido textual (de una o varias frases a párrafos enteros); este contenido requiere, cuanto menos, un tratamiento posterior como bloque de texto
  • También se pueden anidar tablas (subformulario) dentro de la tabla principal.

Lo determinante de esta complejidad es la variedad de formulaciones y su combinación dentro del mismo documento y e incluso de la misma tabla. Ante tanta complejidad, script pensados para trabajar con tablas sencillas fracasan absolutamente, desmintiendo cualquier pretensión de universalidad. Esto sucede con independencia del tipo de documento que sea, .dpf, .docx o .odt. Ante esta complejidad, ¿cuál puede ser la alternativa?.

En principio podríamos pensar en la IA como alternativa; pero ni podemos asegurar el exito ni debemos darlo por supuesto; lo que rompe radicalmente la confiabilidad del recurso y obliga a la revisión sistemática y exhaustiva del resultado, con el coste de tiempo que esto supone. Pero la principal limitación de la IA es la ausencia de confidencialidad que implica, dado que no es viable la anonimación del contenido de las tablas, puesto que es precisamente la recuperación de sus datos (confidenciales) el motivo de la automatización del proceso. Existen alternativas que prometen respetar el anonimato de los datos y garantizar su correcto procesamiento, pero estas soluciones implican cargos económicos que no suelen estar al alcance de los SEO, y que no se justifican por el objetivo último que da sentido al tratamiento de esta documentación.

En resumen, estamos ante un posible callejón sin salida, aunque también cabe verlo como un proceso en el que ciertas tecnologías solamente pueden aportar soluciones parciales y relativamente satisfactorias; siendo necesaria la intervención "manual", la cual, a su vez, también puede ser auxiliada parcialmente mediante la tecnología. A falta de ¿mejores? opciones, menos es nada, pero no deja de ser "curioso" que el proceso inverso (cumplimentar documentos tabulares mediante asociación a bases de datos) haya sido tan temprana y eficientemente resuelto, y su reverso ofrezca tantas resistencia a la automatización. Pero sea curioso o no lo sea tanto, lo cierto es que es lo que hay.

En ausencia de procedimientos sencillos y universales de automatización, vamos a plantear en esta entrada y en las siguientes cómo abordar el tratamiento de estas tablas complejas, empezando por contextualizarlas en escenarios posibles, ya que no deja de resultar hasta cierto punto una anomalía que nodebería ser necesario abordar: lo lógico sería disponder de la base de datos que permitió "escribir" el contenido de esas tablas, pero la realidad es la contraria, muy frecuentemente y por muchas razones, empezando por la escasa práctica de automatizar la escritura de las tablas de este tipo de de domentos, siendo que sus "formularios" se rellenan tan manualmente como se componen los textos explicativos que pueden contener. Otra buena razón es que la procedencia o la antiguedad del documento haga inviable acceder a una supuesta fuente de datos que, casi con toda seguridad, no existe como conjunto estructurado. Para no tener que justificar lo que es difícilmente justificable, es en este segundo escenario donde nos vamos a situar, concretamente en el manejo de documentación propia de cierta antiguedad con fines de análisis de prácticas y de datos digitalizados en soporte documental.

El primer paso será obtener los documentos (tema de cierta complejidad que no corresponde abordar aquí, por lo que lo damos simplemente por resuelto) y el segundo unificar la tipología del soporte, optando en este caso por uniformar todos los documentos como .odt, dado que la arquiteftura interna de este soporte facilita el acceso a su contendio mediante código Python (pero tampoco es en esta entrada donde se tratan estas cuestiones técnicas, por lo que también las damos simplemente por resueltas). Partimos pues de que diponemos de un conjunto suficientemente amplio de documentos que contienen información de interés en una estructura compleja de tablas-formulario. Esta documentación ha sido transformada al formato .odt, lo que facilita su tratamiento mediante un conjunto de bibliotecas Python específicamente desarrolladas para procesar estos documentos. Lo que nos plantearemos a continuación, en las entradas que siguen, es cómo resolver distinto tipo de problemas o retos; desde el acceso al documento y la identificación de las tablas hasta la extracción masiva de datos o la obtención de todos los datos de un documento individual.Todo ello poco a poco y en las entradas que vienen.

miércoles, 22 de abril de 2026

DATOS. Limpieza de datos

Tablas Word (.docx) (II)

Conversión a Excel (xlsx)

Una vez que obtenemos las tablas de un documento .docx, una de las opciones de dar continuidad al procedimiento es transformar dichas tablas en un archivo .xlsx. Otra es hacerlo como.csv, pero la primera opción presenta ventajas sustantivas, como crear una estructura tabular clara para visualización directa, además de manejable desde un servicio de hoja de cálculo. Además incluye una ventaja más: los datos se almacenan como tipos de datos con formatos específicos (string, numéricos, fecha...), dado que así han sido procesados en el script (ver entrada anterior). Por todo esto, esta entrada es mera continuación de la precedente y aporta a aquella el archivo de las tablas y sus contenidos como hoja de cálculo .xlsx. Este es el script que lo hace posible:



# 0. Importamos la bibliotecas necesarias

import pandas as pd
from docx import Document
import os
import unicodedata
import warnings         #Importamos avisos para controlar...

warnings.simplefilter(action='ignore', category=FutureWarning)  # ... que los ocultamos explícitamente

# 1. Función para normalizar las cabeceras de las tablas y facilitar su tratamiento posterior

def normalizar_cabeceras(lista_cabeceras):
    nuevas_cabeceras = []
    for i, nombre in enumerate(lista_cabeceras):
        nombre = str(nombre).strip().lower()
        nombre = "".join(c for c in unicodedata.normalize('NFD', nombre) if unicodedata.category(c) != 'Mn')     # Limpieza básica
        nombre = nombre.replace(" ", "_").replace("/", "_") or f"columna_{i}"
        
        count = 0                            # Código para evitar duplicados (y el error 'duplicate keys') en el nombre de los campos
        temp_nombre = nombre
        while temp_nombre in nuevas_cabeceras:
            temp_nombre = f"{nombre}_{count}"
            count += 1
        nuevas_cabeceras.append(temp_nombre)
    return nuevas_cabeceras

# 2. Función para extraer los datos de las tablas evitando alteraciones previsibles (celdas combinadas)

def extraer_matriz_segura(tabla):
    matriz = []
    for fila in tabla.rows:                 # Extraer contenidos de las tablas evitando errores de celdas combinadas repetidas
        fila_datos = []
        for celda in fila.cells:
            fila_datos.append(celda.text.strip())
        matriz.append(fila_datos)
    
    if not matriz: return []                # Equilibrado de filas, dado que Pandas exige que todas midan lo mismo
    max_cols = max(len(f) for f in matriz)
    for f in matriz:
        while len(f) < max_cols:
            f.append("")                    # Rellenar huecos por celdas combinadas
    return matriz

# 3. Función para obtener las tablas del documento y su contenido

def extractor_universal(ruta_docx):
    try:
        doc = Document(ruta_docx)
        nombre_base = os.path.splitext(ruta_docx)[0]
        ruta_excel = f"{nombre_base}_DB.xlsx"
        lista_dfs = []

        print(f"=== ANALIZANDO: {ruta_docx} ===")

        for i, tabla in enumerate(doc.tables):
            matriz = extraer_matriz_segura(tabla)               # Llamada a función 2
            if len(matriz) < 1: continue

            cabeceras = normalizar_cabeceras(matriz[0])         # Normalizar cabeceras con función 1
            cuerpo = matriz[1:] if len(matriz) > 1 else []

            df = pd.DataFrame(cuerpo, columns=cabeceras)        # Crear el DataFrame de forma más segura
            
            for col in df.columns:                              # Convertir los datos numéricos
                df[col] = pd.to_numeric(df[col], errors='ignore')

            lista_dfs.append(df)
            print(f"Tabla {i+1}: Extraída con éxito ({df.shape[0]} registros).")
            print(df.head(3).to_string(index=False))

        if lista_dfs:                                            # Exportar a Excel, formato xlsx
            with pd.ExcelWriter(ruta_excel, engine='openpyxl') as writer:
                for idx, df in enumerate(lista_dfs):
                    df.to_excel(writer, sheet_name=f"Tabla_{idx+1}", index=False)
            print(f"\n[OK] Guardado en: {ruta_excel}")

    except Exception as e:
        print(f"\n[ERROR]: {e}")

# 4. Llamada a función principal

extractor_universal("tablas.docx")


Además de mostrar los datos por CMD, este script crea una hoja de cálculo Excel en formato .xlsx y almacena en ella las tablas en formato filas-coumnas y los datos que contienen, respetando el tipo de dato supuesto (string, numérico...). Esto tiene varias consecuencias para el tratamiento posterior:

  • se puede acceder a las tablas directamente desde la hoja de cálculo, pero también desde script mediante Pandas trabajando con los datos en función de su tipología.
  • el acceso se hace directamente a datos estructurados, dada la configuración de las tablas resultantes como tales, lo que facilita la manipulación de los datos mediante funciones asociadas a la biblioteca Pandas (y otras)

Además, mediante la función 1 (def normalizar_cabeceras():) evitamos posibles errores en el posterior tratamiento de las tablas. La segunda función (def extraer_matriz_segura()) permite extraer el contenido evitando los errores que derivan de la combinación de tablas.

Pero el núcleo del script sigue siendo la función principal (la 3, def extractor_universal(), que...

  • es a la que llamamdos desde el segmento de ejecución (4, extractor_universal("tablas.docx")), instrucción donde se pasa por parámetro el archivo .docx con el que trabajamos.
  • Esta función principal ejecuta el recorrido del documento, obtiene las tablas que contiene y sus datos...
  • ... asegurando su viabilidad mediante llamada a funciones auxiliares...
    • matriz = extraer_matriz_segura(tabla) y
    • cabeceras = normalizar_cabeceras(matriz[0]))
  • ... generando el dataframe (df = pd.DataFrame(cuerpo, columns=cabeceras))...
  • ... y crea el archivo .xlsx (with pd.ExcelWriter(ruta_excel, engine='openpyxl') as writer:)

El resultado es un .xlsx plenamente operativo, estructurado en hojas (una por tabla) que contienen tablas organizadas para que sus datos puedan ser tratados directamente como datos estructurados y que sea posible trabajar con ellos (consultar y manejo) manualmente, mediante lenguajes de macros o mediante python y sus bibliotecas.

Dada la simplicidad de contenido, pero sobre todo de organización de las tablas, el procedimiento de acceso a tablas y extracción de su contenido ha resultado satisfactorio, haciendo innecesario un tratamiento posterior para que sea posible trabajar directamente con el archivo resultante (.xlsx) y con las tablas que contiene.