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.
No hay comentarios:
Publicar un comentario
Comenta esta entrada