tablewise 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- motor_docling.py +80 -0
- motor_excel.py +500 -0
- preconversion.py +93 -0
- tablewise-1.1.0.dist-info/METADATA +217 -0
- tablewise-1.1.0.dist-info/RECORD +12 -0
- tablewise-1.1.0.dist-info/WHEEL +5 -0
- tablewise-1.1.0.dist-info/entry_points.txt +2 -0
- tablewise-1.1.0.dist-info/licenses/LICENSE +21 -0
- tablewise-1.1.0.dist-info/top_level.txt +6 -0
- tablewise_app.py +361 -0
- tablewise_cli.py +185 -0
- tablewise_core.py +394 -0
motor_docling.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Conversor de PDF/Word/PowerPoint (y escaneados) a Markdown con Docling
|
|
3
|
+
(IBM, open source, 100% local, gratis) -- sustituye a MarkItDown para estos
|
|
4
|
+
formatos porque reconstruye tablas complejas con celdas combinadas en vez
|
|
5
|
+
de volcarlas en crudo (ver README.md, "Piloto de alternativas gratis",
|
|
6
|
+
23/08/2026).
|
|
7
|
+
|
|
8
|
+
Import diferido: docling carga modelos pesados (layout/tabla) la primera
|
|
9
|
+
vez que se usa. Se importa dentro de convertir(), no al importar el
|
|
10
|
+
módulo, para que cargar este fichero sea barato -- solo paga el coste
|
|
11
|
+
quien de verdad convierte un documento (invocado siempre como subprocess
|
|
12
|
+
en un venv aparte, nunca dentro del proceso de la GUI Tkinter).
|
|
13
|
+
|
|
14
|
+
CLI: python motor_docling.py <origen> <destino.md>
|
|
15
|
+
Librería: from motor_docling import convertir
|
|
16
|
+
"""
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from tablewise_core import EXTENSIONES_TEXTUTIL, escribir_atomico, misma_ruta
|
|
22
|
+
from preconversion import con_soffice, con_textutil
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConversionVacia(Exception):
|
|
26
|
+
"""Docling no produjo ningún contenido."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def preconvertir(origen: Path, carpeta: Path) -> Path:
|
|
30
|
+
"""Lo que Docling no lee bien, a un formato que sí:
|
|
31
|
+
- .doc/.rtf/.odt → HTML con textutil (macOS). HTML y no .docx: el
|
|
32
|
+
.docx de textutil aplana las tablas, y la ruta nativa de Docling para
|
|
33
|
+
.doc también (pasa por LibreOffice y saca "ConceptoImporte...").
|
|
34
|
+
- .ppt → .pptx con LibreOffice.
|
|
35
|
+
Lo demás se devuelve tal cual."""
|
|
36
|
+
ext = origen.suffix.lower()
|
|
37
|
+
if ext in EXTENSIONES_TEXTUTIL:
|
|
38
|
+
return con_textutil(origen, carpeta)
|
|
39
|
+
if ext == ".ppt":
|
|
40
|
+
return con_soffice(origen, "pptx", carpeta)
|
|
41
|
+
return origen
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def convertir(origen: Path) -> str:
|
|
45
|
+
from docling.document_converter import DocumentConverter
|
|
46
|
+
|
|
47
|
+
converter = DocumentConverter()
|
|
48
|
+
with tempfile.TemporaryDirectory(prefix="tablewise-") as tmp:
|
|
49
|
+
resultado = converter.convert(str(preconvertir(origen, Path(tmp))))
|
|
50
|
+
md = resultado.document.export_to_markdown()
|
|
51
|
+
if not md.strip():
|
|
52
|
+
raise ConversionVacia(f"Docling no produjo contenido para {origen.name}.")
|
|
53
|
+
return md
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
if len(sys.argv) < 3:
|
|
58
|
+
sys.exit("Uso: python motor_docling.py <origen> <destino.md>")
|
|
59
|
+
origen = Path(sys.argv[1]).expanduser()
|
|
60
|
+
destino = Path(sys.argv[2]).expanduser()
|
|
61
|
+
if not origen.exists():
|
|
62
|
+
sys.exit(f"No existe: {origen}")
|
|
63
|
+
if misma_ruta(origen, destino):
|
|
64
|
+
sys.exit(f"El destino es el propio documento de origen ({origen.name}): "
|
|
65
|
+
"no se sobrescribe.")
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
md = convertir(origen)
|
|
69
|
+
except ConversionVacia as e:
|
|
70
|
+
sys.exit(str(e))
|
|
71
|
+
except Exception as e:
|
|
72
|
+
sys.exit(f"motor_docling falló con {origen.name}: {e}")
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
escribir_atomico(destino, md)
|
|
76
|
+
except (OSError, UnicodeError) as e:
|
|
77
|
+
# Sin esto, un destino no escribible acababa en traceback crudo,
|
|
78
|
+
# que la app enseña tal cual en el diálogo de error.
|
|
79
|
+
sys.exit(f"No se pudo escribir {destino}: {e}")
|
|
80
|
+
print(f"Guardado en {destino} ({len(md)} caracteres)")
|
motor_excel.py
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Conversor de Excel nativo (.xlsx/.xlsm con celdas combinadas) a Markdown SIN
|
|
3
|
+
IA -- lee directamente las celdas combinadas (merged_cells) de openpyxl y
|
|
4
|
+
propaga el valor de cada cabecera combinada a todas las columnas que cubre.
|
|
5
|
+
Un Excel nativo trae esa información exacta; no hace falta que nadie la
|
|
6
|
+
"adivine" a partir de una imagen.
|
|
7
|
+
|
|
8
|
+
Nace de probar_openpyxl_merge.py (piloto 23/08/2026, ver README.md,
|
|
9
|
+
"Piloto de alternativas gratis") -- promovido a motor de producción de
|
|
10
|
+
tablewise_app.py tras el careo de ese piloto, y corregido en un segundo
|
|
11
|
+
careo (ver comentarios "hallazgo de careo" abajo).
|
|
12
|
+
|
|
13
|
+
No produce una tabla Markdown de cabecera única: un USALI (y en general
|
|
14
|
+
un cierre financiero real) tiene varias filas de cabecera apiladas, así
|
|
15
|
+
que se vuelca la rejilla completa con sintaxis de pipes para legibilidad,
|
|
16
|
+
sin la fila separadora `|---|` de una tabla de cabecera única (no
|
|
17
|
+
aplicaría: no hay UNA fila de cabecera).
|
|
18
|
+
|
|
19
|
+
CLI: python motor_excel.py <origen.xlsx> <destino.md>
|
|
20
|
+
Librería: from motor_excel import convertir
|
|
21
|
+
"""
|
|
22
|
+
import datetime as dt
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
import unicodedata
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from tablewise_core import escribir_atomico, misma_ruta
|
|
30
|
+
from preconversion import con_soffice
|
|
31
|
+
|
|
32
|
+
from openpyxl import load_workbook
|
|
33
|
+
from openpyxl.cell.cell import MergedCell
|
|
34
|
+
from openpyxl.utils import range_boundaries
|
|
35
|
+
from openpyxl.worksheet.formula import ArrayFormula, DataTableFormula
|
|
36
|
+
|
|
37
|
+
# Techo de seguridad ANTES de escanear ninguna celda: openpyxl reporta
|
|
38
|
+
# max_row/max_column hasta la última celda con formato aplicado, no la
|
|
39
|
+
# última con contenido -- un XLSX con formato pintado hasta el límite de
|
|
40
|
+
# Excel (1048576 x 16384) convertiría el escaneo en decenas de millones de
|
|
41
|
+
# celdas y colgaría el proceso (hallazgo de careo, ALTA, 23/08: el primer
|
|
42
|
+
# intento de acotar el rango seguía iterando sin límite ANTES de saber
|
|
43
|
+
# dónde parar). Un USALI real ronda 100 filas x 100 columnas por hoja --
|
|
44
|
+
# esto deja margen generoso sin abrir la puerta a un XLSX hostil.
|
|
45
|
+
LIMITE_FILAS = 5000
|
|
46
|
+
LIMITE_COLUMNAS = 500
|
|
47
|
+
|
|
48
|
+
# Una fórmula sin valor cacheado (libro guardado sin recalcular) sale así
|
|
49
|
+
# en vez de vacía: una celda vacía en un cierre financiero puede leerse
|
|
50
|
+
# como un cero, y esto no lo es.
|
|
51
|
+
MARCA_FORMULA = "(formula not calculated)"
|
|
52
|
+
|
|
53
|
+
# Celdas que se pueden recorrer por hoja al expandir matriciales
|
|
54
|
+
# multicelda: una real rara vez pasa de unos cientos; esto acota el trabajo
|
|
55
|
+
# (y las marcas) de un libro hostil.
|
|
56
|
+
MAX_CELDAS_MATRICIALES = 10_000
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ConversionVacia(Exception):
|
|
60
|
+
"""El libro no produjo ningún contenido."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class HojaDemasiadoGrande(Exception):
|
|
64
|
+
"""Una hoja supera el techo de seguridad -- no se procesa esa hoja."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _limpia_formato(formato: str) -> str:
|
|
68
|
+
"""Formato numérico sin lo que es texto literal: lo entrecomillado
|
|
69
|
+
(0.00" %"), lo escapado con barra (0.00\\%) y las etiquetas entre
|
|
70
|
+
corchetes ([$%-409], [Red], [h])."""
|
|
71
|
+
return re.sub(r'"[^"]*"|\\.|\[[^\]]*\]', "", formato or "")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_CONDICION = re.compile(r"\[(<=|>=|<>|<|>|=)\s*(-?\d+(?:\.\d+)?)\]")
|
|
75
|
+
_OPERADORES = {
|
|
76
|
+
"<": lambda a, b: a < b, ">": lambda a, b: a > b, "=": lambda a, b: a == b,
|
|
77
|
+
"<=": lambda a, b: a <= b, ">=": lambda a, b: a >= b, "<>": lambda a, b: a != b,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _secciones(formato: str) -> list[str]:
|
|
82
|
+
# ";" separa secciones salvo dentro de comillas ("a;b") o escapado (\;).
|
|
83
|
+
secciones, actual = [], ""
|
|
84
|
+
for trozo in re.findall(r'"[^"]*"?|\\.?|;|[^";\\]+', formato or ""):
|
|
85
|
+
if trozo == ";":
|
|
86
|
+
secciones.append(actual)
|
|
87
|
+
actual = ""
|
|
88
|
+
else:
|
|
89
|
+
actual += trozo
|
|
90
|
+
return secciones + [actual]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _cumple(seccion: str, v) -> bool | None:
|
|
94
|
+
"""True/False si la sección lleva condición ([>=100]...), None si no."""
|
|
95
|
+
m = _CONDICION.search(seccion)
|
|
96
|
+
if not m:
|
|
97
|
+
return None
|
|
98
|
+
return _OPERADORES[m.group(1)](v, float(m.group(2)))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _seccion(formato: str, v) -> str:
|
|
102
|
+
"""La sección del formato que Excel aplica a `v` (ya sin literales).
|
|
103
|
+
Sin condiciones, por signo: "pos;neg;cero;texto" -- un "%" en la
|
|
104
|
+
sección del cero o del texto no convierte un 0,5 en 50% (hallazgo de
|
|
105
|
+
careo, BAJA, 04/10). Con condiciones ([<1]0.0%;0), como Excel: la 1ª
|
|
106
|
+
si cumple la suya, si no la 2ª (si cumple o no tiene), si no la 3ª."""
|
|
107
|
+
secciones = _secciones(formato)
|
|
108
|
+
condiciones = [_cumple(s, v) for s in secciones[:2]]
|
|
109
|
+
if any(c is not None for c in condiciones):
|
|
110
|
+
if condiciones[0]:
|
|
111
|
+
elegida = secciones[0]
|
|
112
|
+
elif len(secciones) > 1 and condiciones[1] is not False:
|
|
113
|
+
elegida = secciones[1]
|
|
114
|
+
elif len(secciones) > 2:
|
|
115
|
+
elegida = secciones[2]
|
|
116
|
+
else:
|
|
117
|
+
elegida = secciones[0]
|
|
118
|
+
return _limpia_formato(elegida)
|
|
119
|
+
secciones = [_limpia_formato(s) for s in secciones]
|
|
120
|
+
if v < 0 and len(secciones) > 1:
|
|
121
|
+
return secciones[1]
|
|
122
|
+
if v == 0 and len(secciones) > 2:
|
|
123
|
+
return secciones[2]
|
|
124
|
+
return secciones[0]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _es_porcentaje(formato: str, v=1) -> bool:
|
|
128
|
+
return "%" in _seccion(formato, v)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _tokens(formato: str) -> str:
|
|
132
|
+
limpio = _limpia_formato(formato).lower()
|
|
133
|
+
return "" if limpio in ("", "general", "@") else limpio
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _es_solo_hora(formato: str) -> bool:
|
|
137
|
+
"""h:mm, mm:ss... con horas o segundos y sin día ni año. "m" a secas es
|
|
138
|
+
MES: "mmmm" es una cabecera de mes, no una hora (hallazgo de careo,
|
|
139
|
+
ALTA, 04/10: salía "00:00")."""
|
|
140
|
+
t = _tokens(formato)
|
|
141
|
+
return bool(re.search(r"[hs]", t)) and not re.search(r"[dye]", t)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _es_formato_de_fecha(formato: str) -> bool:
|
|
145
|
+
# Día, año ("e": año de era) o mes. "General" lleva una "e" y no es fecha.
|
|
146
|
+
t = _tokens(formato)
|
|
147
|
+
return bool(re.search(r"[dyem]", t)) and not _es_solo_hora(formato)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _hora(v: dt.time, formato: str) -> str:
|
|
151
|
+
"""Hora como la enseña Excel con su formato. Redondeo al segundo (o a
|
|
152
|
+
la fracción que muestre ss.0/ss.00), medio hacia arriba: openpyxl deja
|
|
153
|
+
0.99999999 en 23:59:59.999, que Excel enseña como 0:00 (round() de
|
|
154
|
+
Python va al par). Segundos solo si el formato los muestra o, en
|
|
155
|
+
General, si los hay; mm:ss sin horas; AM/PM y A/P en 12 h."""
|
|
156
|
+
t = _tokens(formato)
|
|
157
|
+
general = not t
|
|
158
|
+
m = re.search(r"s\.(0+)", t)
|
|
159
|
+
decimales = len(m.group(1)) if m else 0
|
|
160
|
+
unidades = 10 ** decimales
|
|
161
|
+
exacto = (v.hour * 3600 + v.minute * 60 + v.second) * unidades + v.microsecond * unidades / 1e6
|
|
162
|
+
total = int(exacto + 0.5) % (86400 * unidades)
|
|
163
|
+
total, fraccion = divmod(total, unidades)
|
|
164
|
+
horas, resto = divmod(total, 3600)
|
|
165
|
+
minutos, segundos = divmod(resto, 60)
|
|
166
|
+
|
|
167
|
+
sufijo = ""
|
|
168
|
+
doce_horas = "am/pm" in t or "a/p" in t
|
|
169
|
+
if doce_horas:
|
|
170
|
+
tarde = horas >= 12
|
|
171
|
+
horas = horas % 12 or 12
|
|
172
|
+
sufijo = (" PM" if tarde else " AM") if "am/pm" in t else (" P" if tarde else " A")
|
|
173
|
+
|
|
174
|
+
seg = f"{segundos:02d}" + (f".{fraccion:0{decimales}d}" if decimales else "")
|
|
175
|
+
# La "m" de "am/pm" no son minutos.
|
|
176
|
+
t = t.replace("am/pm", "").replace("a/p", "")
|
|
177
|
+
if not general and "h" not in t and "m" not in t and "s" in t:
|
|
178
|
+
return seg # ss.00: solo segundos
|
|
179
|
+
if not general and "h" in t and "m" not in t and "s" not in t:
|
|
180
|
+
return f"{horas:02d}{sufijo}" if "hh" in t else f"{horas}{sufijo}" # h AM/PM
|
|
181
|
+
if not general and "h" not in t and "s" in t:
|
|
182
|
+
return f"{minutos:02d}:{seg}" # mm:ss
|
|
183
|
+
if "s" in t or (general and segundos):
|
|
184
|
+
return f"{horas:02d}:{minutos:02d}:{seg}{sufijo}"
|
|
185
|
+
return f"{horas:02d}:{minutos:02d}{sufijo}"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _duracion(v: dt.timedelta, formato: str) -> str:
|
|
189
|
+
"""Duración como los formatos acumulados de Excel: [h]:mm (por defecto,
|
|
190
|
+
sin el "1 day, 2:00:00" de str()), [mm]:ss en minutos totales, [ss] en
|
|
191
|
+
segundos totales, con las fracciones de ss.0 redondeadas hacia arriba."""
|
|
192
|
+
t = _tokens(formato)
|
|
193
|
+
bruto = (formato or "").lower()
|
|
194
|
+
m = re.search(r"s\]?\.(0+)", bruto) # ss.0 o [ss].00
|
|
195
|
+
decimales = len(m.group(1)) if m else 0
|
|
196
|
+
unidades = 10 ** decimales
|
|
197
|
+
exacto = v.total_seconds() * unidades
|
|
198
|
+
total = int(abs(exacto) + 0.5)
|
|
199
|
+
signo = "-" if exacto < 0 and total else ""
|
|
200
|
+
total, fraccion = divmod(total, unidades)
|
|
201
|
+
frac = f".{fraccion:0{decimales}d}" if decimales else ""
|
|
202
|
+
if re.search(r"\[s+\]", bruto):
|
|
203
|
+
return f"{signo}{total}{frac}"
|
|
204
|
+
minutos_totales, segundos = divmod(total, 60)
|
|
205
|
+
acumulado = re.search(r"\[(m+)\]", bruto)
|
|
206
|
+
if acumulado:
|
|
207
|
+
relleno = "02d" if len(acumulado.group(1)) > 1 else "d"
|
|
208
|
+
return f"{signo}{minutos_totales:{relleno}}:{segundos:02d}{frac}"
|
|
209
|
+
horas, minutos = divmod(minutos_totales, 60)
|
|
210
|
+
if re.fullmatch(r"(\[\$[^\]]*\])?\[h+\]", bruto.strip()): # [h] a secas
|
|
211
|
+
return f"{signo}{horas}"
|
|
212
|
+
if "s" in t or (not t and (segundos or fraccion)):
|
|
213
|
+
return f"{signo}{horas}:{minutos:02d}:{segundos:02d}{frac}"
|
|
214
|
+
return f"{signo}{horas}:{minutos:02d}"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _formatea(v, formato="General"):
|
|
218
|
+
if v is None:
|
|
219
|
+
return ""
|
|
220
|
+
if (isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
221
|
+
and _es_porcentaje(formato, v)):
|
|
222
|
+
# Ocupación 0.8769 con formato "0.0%": Excel enseña 87.7%. Un
|
|
223
|
+
# "0.88" suelto se podía leer como 0,88 unidades.
|
|
224
|
+
s = f"{v * 100:.2f}%"
|
|
225
|
+
if s == "-0.00%":
|
|
226
|
+
s = "0.00%"
|
|
227
|
+
elif isinstance(v, float):
|
|
228
|
+
# Redondeo a 2 decimales: la precisión binaria completa
|
|
229
|
+
# (0.8769304604573576) no aporta nada a una IA/RAG y triplica
|
|
230
|
+
# el tamaño del fichero sin motivo.
|
|
231
|
+
s = f"{v:.2f}"
|
|
232
|
+
if s == "-0.00": # -0.001 redondeado: el signo solo confunde
|
|
233
|
+
s = "0.00"
|
|
234
|
+
elif isinstance(v, dt.datetime) and _es_solo_hora(formato):
|
|
235
|
+
# Hora sin fecha (h:mm) con un valor de 24 h o más: openpyxl lo da
|
|
236
|
+
# como datetime de 1900; Excel enseña solo la hora.
|
|
237
|
+
s = _hora(v.time(), formato)
|
|
238
|
+
elif isinstance(v, dt.time) and v == dt.time(0, 0) and _es_formato_de_fecha(formato):
|
|
239
|
+
# Un 0 en una columna con formato de fecha: openpyxl da time(0, 0),
|
|
240
|
+
# y "00:00" se leería como una hora. Es un cero.
|
|
241
|
+
s = "0"
|
|
242
|
+
elif isinstance(v, dt.datetime):
|
|
243
|
+
# openpyxl devuelve datetime para cualquier celda con formato de
|
|
244
|
+
# fecha: "2026-07-01 00:00:00" es ruido -- la hora solo si la hay.
|
|
245
|
+
if v.time() == dt.time(0, 0):
|
|
246
|
+
s = v.strftime("%Y-%m-%d")
|
|
247
|
+
elif v.second or v.microsecond:
|
|
248
|
+
s = v.strftime("%Y-%m-%d %H:%M:%S")
|
|
249
|
+
else:
|
|
250
|
+
s = v.strftime("%Y-%m-%d %H:%M")
|
|
251
|
+
elif isinstance(v, dt.time):
|
|
252
|
+
s = _hora(v, formato)
|
|
253
|
+
elif isinstance(v, dt.timedelta):
|
|
254
|
+
s = _duracion(v, formato)
|
|
255
|
+
else:
|
|
256
|
+
s = str(v)
|
|
257
|
+
# Escapar lo que rompería la rejilla de pipes: un "|" o un salto de
|
|
258
|
+
# línea dentro de una celda desplazaría las columnas de toda esa fila.
|
|
259
|
+
# Las barras justo antes de un "|" se doblan: un "\|" literal salía
|
|
260
|
+
# como "\\|" (barra escapada + pipe separador). El resto de barras
|
|
261
|
+
# se deja tal cual (C:\ruta) para no ensuciar el texto del RAG.
|
|
262
|
+
s = re.sub(r"(\\*)\|", lambda m: m.group(1) * 2 + "\\|", s)
|
|
263
|
+
# splitlines() corta en TODOS los saltos que reconoce Python (\v, \f,
|
|
264
|
+
# \x1c-\x1e, \x85, \u2028, \u2029, además de \n y \r): un lector que
|
|
265
|
+
# parta por líneas no debe ver la fila de una celda partida en dos.
|
|
266
|
+
s = " ".join(s.splitlines())
|
|
267
|
+
# Una celda de solo espacios es una celda "borrada" a mano: cuenta como
|
|
268
|
+
# vacía para el filtro de filas/columnas espaciadoras. La sangría de
|
|
269
|
+
# una etiqueta con texto se conserva (marca jerarquía en un USALI).
|
|
270
|
+
# Invisibles (ZWSP, BOM) también: una celda con solo eso mantenía viva
|
|
271
|
+
# una fila o columna separadora (hallazgo de careo, BAJA, 04/10).
|
|
272
|
+
visible = any(not ch.isspace() and unicodedata.category(ch) != "Cf" for ch in s)
|
|
273
|
+
return s if visible else ""
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _escanear(hoja, hoja_formulas):
|
|
277
|
+
"""Recorre la hoja UNA sola vez y devuelve (max_fila, max_col,
|
|
278
|
+
celdas_formula_sin_valor, n_formulas_con_valor, refs de matriciales
|
|
279
|
+
demasiado grandes para expandir). Una celda cuenta como
|
|
280
|
+
"contenido real" si tiene valor en la copia data_only=True, O si es una
|
|
281
|
+
fórmula sin valor cacheado (data_only=True la ve como None, pero
|
|
282
|
+
data_only=False ve el texto "=..."). Sin esto último, una hoja hecha
|
|
283
|
+
solo de fórmulas sin recalcular se recortaba a un rango vacío y el aviso
|
|
284
|
+
nunca llegaba a emitirse (hallazgo de careo, MEDIA, 23/08)."""
|
|
285
|
+
max_fila = max_col = 0
|
|
286
|
+
sin_valor = {} # (fila, col) -> None: conjunto ordenado, sin duplicados
|
|
287
|
+
con_valor = 0
|
|
288
|
+
presupuesto = MAX_CELDAS_MATRICIALES
|
|
289
|
+
grandes = []
|
|
290
|
+
for fila_datos, fila_formulas in zip(hoja.iter_rows(), hoja_formulas.iter_rows()):
|
|
291
|
+
for c, cf in zip(fila_datos, fila_formulas):
|
|
292
|
+
# Las matriciales y las tablas de datos no llegan como str
|
|
293
|
+
# "=...", sino como objeto -- sin esto no se marcaban ni contaban.
|
|
294
|
+
es_formula = (
|
|
295
|
+
isinstance(cf.value, (ArrayFormula, DataTableFormula))
|
|
296
|
+
or (isinstance(cf.value, str) and cf.value.startswith("="))
|
|
297
|
+
)
|
|
298
|
+
if c.value is not None or es_formula:
|
|
299
|
+
max_fila = max(max_fila, c.row)
|
|
300
|
+
max_col = max(max_col, c.column)
|
|
301
|
+
if not es_formula:
|
|
302
|
+
continue
|
|
303
|
+
if c.value is not None:
|
|
304
|
+
con_valor += 1
|
|
305
|
+
continue
|
|
306
|
+
sin_valor[(c.row, c.column)] = None
|
|
307
|
+
if isinstance(cf.value, ArrayFormula) and cf.value.ref:
|
|
308
|
+
presupuesto, f_max, c_max = _expandir_matricial(
|
|
309
|
+
hoja, c, cf.value.ref, sin_valor, presupuesto, grandes)
|
|
310
|
+
max_fila, max_col = max(max_fila, f_max), max(max_col, c_max)
|
|
311
|
+
return max_fila, max_col, list(sin_valor), con_valor, grandes
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _expandir_matricial(hoja, ancla, ref, sin_valor, presupuesto, grandes):
|
|
315
|
+
"""Una matricial multicelda solo lleva el ArrayFormula en la ancla: el
|
|
316
|
+
resto de su rango llega vacío y salía en blanco en vez de marcado.
|
|
317
|
+
Defensas (hallazgos de careo, 05/10): solo si el rango empieza en la
|
|
318
|
+
propia ancla (como garantiza Excel), recortado al techo de seguridad,
|
|
319
|
+
con un presupuesto de celdas por hoja (muchas anclas hostiles
|
|
320
|
+
multiplicaban el trabajo), sin celdas combinadas (MergedCell no admite
|
|
321
|
+
la marca) y sin crear celdas al consultar."""
|
|
322
|
+
try:
|
|
323
|
+
min_c, min_f, max_c, max_f = range_boundaries(ref)
|
|
324
|
+
except (ValueError, TypeError):
|
|
325
|
+
return presupuesto, 0, 0
|
|
326
|
+
if (min_f, min_c) != (ancla.row, ancla.column):
|
|
327
|
+
return presupuesto, 0, 0
|
|
328
|
+
max_f, max_c = min(max_f, LIMITE_FILAS), min(max_c, LIMITE_COLUMNAS)
|
|
329
|
+
if (max_f - min_f + 1) * (max_c - min_c + 1) > presupuesto:
|
|
330
|
+
# No cabe: solo la ancla, y convertir() lo avisa en el .md -- ni
|
|
331
|
+
# corte silencioso a medias ni miles de marcas (careo, BAJA, 05/10).
|
|
332
|
+
grandes.append(ref)
|
|
333
|
+
return presupuesto, 0, 0
|
|
334
|
+
f_max = c_max = 0
|
|
335
|
+
for f in range(min_f, max_f + 1):
|
|
336
|
+
for col in range(min_c, max_c + 1):
|
|
337
|
+
if presupuesto <= 0:
|
|
338
|
+
return presupuesto, f_max, c_max
|
|
339
|
+
presupuesto -= 1 # cuenta lo recorrido, no solo lo marcado
|
|
340
|
+
if (f, col) in sin_valor:
|
|
341
|
+
continue
|
|
342
|
+
celda = hoja._cells.get((f, col))
|
|
343
|
+
if isinstance(celda, MergedCell) or (celda is not None and celda.value is not None):
|
|
344
|
+
continue
|
|
345
|
+
sin_valor[(f, col)] = None
|
|
346
|
+
f_max, c_max = max(f_max, f), max(c_max, col)
|
|
347
|
+
return presupuesto, f_max, c_max
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def convertir(origen: Path) -> tuple[str, int]:
|
|
351
|
+
"""Devuelve (markdown, nº de celdas con fórmula sin valor cacheado).
|
|
352
|
+
El segundo valor es un aviso -- 0 no garantiza que no falte nada, solo
|
|
353
|
+
que no se detectó ninguna fórmula sin recalcular.
|
|
354
|
+
Un .xls (Excel 97-2003) se pasa antes a .xlsx con LibreOffice: openpyxl
|
|
355
|
+
no lee el formato binario, y así se conservan las combinadas."""
|
|
356
|
+
if origen.suffix.lower() == ".xls":
|
|
357
|
+
with tempfile.TemporaryDirectory(prefix="tablewise-") as tmp:
|
|
358
|
+
return _convertir_xlsx(con_soffice(origen, "xlsx", Path(tmp)), origen)
|
|
359
|
+
return _convertir_xlsx(origen, origen)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _convertir_xlsx(origen: Path, original: Path) -> tuple[str, int]:
|
|
363
|
+
wb = load_workbook(str(origen), data_only=True)
|
|
364
|
+
wb_formulas = load_workbook(str(origen), data_only=False)
|
|
365
|
+
|
|
366
|
+
# Primera pasada: rango útil de cada hoja y estado de sus fórmulas.
|
|
367
|
+
hojas = []
|
|
368
|
+
hojas_omitidas = []
|
|
369
|
+
matriciales_grandes = []
|
|
370
|
+
total_sin_valor = total_con_valor = 0
|
|
371
|
+
for hoja, hoja_formulas in zip(wb.worksheets, wb_formulas.worksheets):
|
|
372
|
+
if hoja.max_row > LIMITE_FILAS or hoja.max_column > LIMITE_COLUMNAS:
|
|
373
|
+
hojas_omitidas.append(
|
|
374
|
+
f"{hoja.title} ({hoja.max_row}x{hoja.max_column}, supera "
|
|
375
|
+
f"el límite de seguridad {LIMITE_FILAS}x{LIMITE_COLUMNAS})"
|
|
376
|
+
)
|
|
377
|
+
continue
|
|
378
|
+
max_fila, max_col, sin_valor, con_valor, grandes = _escanear(hoja, hoja_formulas)
|
|
379
|
+
matriciales_grandes += [f"{hoja.title}!{ref}" for ref in grandes]
|
|
380
|
+
# Una cabecera combinada con contenido cubre todo su rango, aunque
|
|
381
|
+
# sus celdas interiores (MergedCell) estén vacías: sin esto se
|
|
382
|
+
# recortaba si iba más allá de la última celda con dato.
|
|
383
|
+
for rango in hoja.merged_cells.ranges:
|
|
384
|
+
ancla = hoja_formulas.cell(row=rango.min_row, column=rango.min_col).value
|
|
385
|
+
if ancla is not None:
|
|
386
|
+
max_fila = max(max_fila, rango.max_row)
|
|
387
|
+
max_col = max(max_col, rango.max_col)
|
|
388
|
+
total_sin_valor += len(sin_valor)
|
|
389
|
+
total_con_valor += con_valor
|
|
390
|
+
hojas.append((hoja, max_fila, max_col, sin_valor))
|
|
391
|
+
|
|
392
|
+
# Una fórmula que devuelve "" (=SI(...;"";x), habitual para ocultar
|
|
393
|
+
# ceros) llega como None igual que una sin calcular. Solo se puede
|
|
394
|
+
# distinguir a nivel de libro: si ALGUNA fórmula trae valor, el libro se
|
|
395
|
+
# recalculó y esos None son "" legítimos (hallazgo de careo, MEDIA,
|
|
396
|
+
# 04/10). Excel recalcula el libro entero al guardar.
|
|
397
|
+
libro_sin_recalcular = total_sin_valor > 0 and total_con_valor == 0
|
|
398
|
+
|
|
399
|
+
partes = []
|
|
400
|
+
for hoja, max_fila, max_col, sin_valor in hojas:
|
|
401
|
+
if max_fila == 0 or max_col == 0:
|
|
402
|
+
continue # hoja vacía
|
|
403
|
+
|
|
404
|
+
if libro_sin_recalcular:
|
|
405
|
+
# Antes de propagar combinadas: así una cabecera combinada que
|
|
406
|
+
# sea fórmula repite la marca en todas sus celdas.
|
|
407
|
+
for fila, col in sin_valor:
|
|
408
|
+
hoja.cell(row=fila, column=col).value = MARCA_FORMULA
|
|
409
|
+
|
|
410
|
+
# Propaga el valor de cada rango combinado a todas sus celdas --
|
|
411
|
+
# esto es lo que elimina las columnas "Unnamed": la cabecera ya no
|
|
412
|
+
# está vacía porque Excel la pintaba combinada, ahora está repetida
|
|
413
|
+
# en cada celda.
|
|
414
|
+
for rango in list(hoja.merged_cells.ranges):
|
|
415
|
+
ancla = hoja.cell(row=rango.min_row, column=rango.min_col)
|
|
416
|
+
valor, formato = ancla.value, ancla.number_format
|
|
417
|
+
hoja.unmerge_cells(str(rango))
|
|
418
|
+
for fila in range(rango.min_row, rango.max_row + 1):
|
|
419
|
+
for col in range(rango.min_col, rango.max_col + 1):
|
|
420
|
+
celda = hoja.cell(row=fila, column=col)
|
|
421
|
+
celda.value = valor
|
|
422
|
+
# El formato viaja con el valor: un 25% combinado no
|
|
423
|
+
# puede salir como 0.25 en sus celdas interiores.
|
|
424
|
+
celda.number_format = formato
|
|
425
|
+
|
|
426
|
+
rejilla = [
|
|
427
|
+
[_formatea(c.value, c.number_format) for c in fila]
|
|
428
|
+
for fila in hoja.iter_rows(min_row=1, max_row=max_fila, min_col=1, max_col=max_col)
|
|
429
|
+
]
|
|
430
|
+
# Filas y columnas separadoras (habituales en un USALI): "| |" no
|
|
431
|
+
# aporta nada a una IA/RAG y solo gasta tokens. Se decide DESPUÉS de
|
|
432
|
+
# propagar combinadas: una columna cubierta por una cabecera
|
|
433
|
+
# combinada ya no está vacía.
|
|
434
|
+
rejilla = [fila for fila in rejilla if any(fila)]
|
|
435
|
+
if not rejilla:
|
|
436
|
+
# Solo tenía celdas de puro espacio: sin esto quedaba un
|
|
437
|
+
# "## Título" huérfano y el libro no contaba como vacío.
|
|
438
|
+
continue
|
|
439
|
+
columnas = [i for i in range(max_col) if any(fila[i] for fila in rejilla)]
|
|
440
|
+
|
|
441
|
+
# Una hidden sheet suele ser una tabla auxiliar de cálculo: se
|
|
442
|
+
# incluye (puede tener datos), pero señalada para que la IA/RAG no
|
|
443
|
+
# la tome por parte del informe que ve el lector.
|
|
444
|
+
oculta = " (hidden sheet)" if hoja.sheet_state != "visible" else ""
|
|
445
|
+
partes.append(f"## {hoja.title}{oculta}\n")
|
|
446
|
+
for fila in rejilla:
|
|
447
|
+
partes.append("| " + " | ".join(fila[i] for i in columnas) + " |")
|
|
448
|
+
partes.append("")
|
|
449
|
+
|
|
450
|
+
if libro_sin_recalcular and matriciales_grandes:
|
|
451
|
+
partes.append(
|
|
452
|
+
"**WARNING: array formula(s) not recalculated and too large "
|
|
453
|
+
"to mark cell by cell -- only their first cell is marked:**\n- "
|
|
454
|
+
+ "\n- ".join(matriciales_grandes)
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
if hojas_omitidas:
|
|
458
|
+
partes.append(
|
|
459
|
+
"**WARNING: sheet(s) skipped for exceeding the safety limit -- "
|
|
460
|
+
"review by hand in case they hold something relevant:**\n- "
|
|
461
|
+
+ "\n- ".join(hojas_omitidas)
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
md = "\n".join(partes)
|
|
465
|
+
if not md.strip():
|
|
466
|
+
raise ConversionVacia(f"{original.name} no produjo ningún contenido (¿libro vacío?).")
|
|
467
|
+
return md, total_sin_valor if libro_sin_recalcular else 0
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
if __name__ == "__main__":
|
|
471
|
+
if len(sys.argv) < 3:
|
|
472
|
+
sys.exit("Uso: python motor_excel.py <origen.xlsx> <destino.md>")
|
|
473
|
+
origen = Path(sys.argv[1]).expanduser()
|
|
474
|
+
destino = Path(sys.argv[2]).expanduser()
|
|
475
|
+
if not origen.exists():
|
|
476
|
+
sys.exit(f"No existe: {origen}")
|
|
477
|
+
if misma_ruta(origen, destino):
|
|
478
|
+
sys.exit(f"El destino es el propio documento de origen ({origen.name}): "
|
|
479
|
+
"no se sobrescribe.")
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
md, avisos = convertir(origen)
|
|
483
|
+
except ConversionVacia as e:
|
|
484
|
+
sys.exit(str(e))
|
|
485
|
+
except Exception as e:
|
|
486
|
+
sys.exit(f"motor_excel falló con {origen.name}: {e}")
|
|
487
|
+
|
|
488
|
+
try:
|
|
489
|
+
escribir_atomico(destino, md)
|
|
490
|
+
except (OSError, UnicodeError) as e:
|
|
491
|
+
# Sin esto, un destino no escribible acababa en traceback crudo,
|
|
492
|
+
# que la app enseña tal cual en el diálogo de error.
|
|
493
|
+
sys.exit(f"No se pudo escribir {destino}: {e}")
|
|
494
|
+
print(f"Guardado en {destino} ({len(md)} caracteres)")
|
|
495
|
+
if avisos:
|
|
496
|
+
print(
|
|
497
|
+
f"AVISO: {avisos} celda(s) con fórmula sin valor cacheado "
|
|
498
|
+
f"(marcadas como {MARCA_FORMULA!r} -- abrir y guardar el libro "
|
|
499
|
+
"en Excel para recalcularlas)."
|
|
500
|
+
)
|
preconversion.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Preconversión de formatos que ningún motor lee directamente, con
|
|
3
|
+
herramientas gratuitas del propio equipo:
|
|
4
|
+
|
|
5
|
+
- textutil (viene con macOS): .doc/.rtf/.odt → HTML, que Docling sí lee.
|
|
6
|
+
HTML y no .docx: el .docx de textutil aplana las tablas en párrafos.
|
|
7
|
+
- LibreOffice (soffice, si está instalado): .xls → .xlsx (motor_excel,
|
|
8
|
+
conserva celdas combinadas) y .ppt → .pptx (Docling).
|
|
9
|
+
|
|
10
|
+
Sin dependencias de terceros: lo importan los motores (venv propio) y la
|
|
11
|
+
app (para saber si ofrecer .xls/.ppt).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from tablewise_core import nombre_seguro
|
|
21
|
+
|
|
22
|
+
# Lanzada desde Finder, la app no hereda el PATH de Homebrew: se miran
|
|
23
|
+
# también las rutas de instalación habituales en macOS.
|
|
24
|
+
RUTAS_SOFFICE = [
|
|
25
|
+
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
|
|
26
|
+
"/opt/homebrew/bin/soffice",
|
|
27
|
+
"/usr/local/bin/soffice",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# Un fichero patológico no debe dejar el motor colgado para siempre.
|
|
31
|
+
TIMEOUT_SEGUNDOS = 300
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def buscar_soffice() -> str | None:
|
|
35
|
+
encontrado = shutil.which("soffice")
|
|
36
|
+
if encontrado:
|
|
37
|
+
return encontrado
|
|
38
|
+
for ruta in RUTAS_SOFFICE:
|
|
39
|
+
if os.path.isfile(ruta) and os.access(ruta, os.X_OK):
|
|
40
|
+
return ruta
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ejecutar(cmd: list[str], origen: Path, salida: Path, herramienta: str) -> Path:
|
|
45
|
+
try:
|
|
46
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=TIMEOUT_SEGUNDOS)
|
|
47
|
+
except subprocess.TimeoutExpired as e:
|
|
48
|
+
raise RuntimeError(f"{herramienta} tardó más de {TIMEOUT_SEGUNDOS}s con {origen.name}") from e
|
|
49
|
+
except OSError as e:
|
|
50
|
+
raise RuntimeError(f"No se pudo lanzar {herramienta} para {origen.name}: {e}") from e
|
|
51
|
+
# textutil devuelve 0 incluso si no pudo leer el fichero, y soffice
|
|
52
|
+
# también cuando no reconoce el formato: lo que cuenta es la salida.
|
|
53
|
+
if r.returncode != 0 or not salida.exists():
|
|
54
|
+
detalle = (r.stderr or r.stdout).strip()
|
|
55
|
+
raise RuntimeError(f"{herramienta} no pudo leer {origen.name}: {detalle}")
|
|
56
|
+
return salida
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def con_textutil(origen: Path, carpeta: Path) -> Path:
|
|
60
|
+
""".doc/.rtf/.odt → HTML. Nombre de salida fijo: "{stem}.html" es un
|
|
61
|
+
byte más largo que "{stem}.doc" y con nombres de 255 bytes superaba
|
|
62
|
+
el máximo del sistema de ficheros (hallazgo de careo, BAJA, 04/10)."""
|
|
63
|
+
carpeta.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
html = carpeta / "entrada.html"
|
|
65
|
+
return _ejecutar(["textutil", "-convert", "html", "-encoding", "UTF-8",
|
|
66
|
+
"-output", str(html), str(origen)], origen, html, "textutil")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def con_soffice(origen: Path, formato: str, carpeta: Path) -> Path:
|
|
70
|
+
"""Convierte con LibreOffice a `formato` ("xlsx", "pptx"). Trabaja
|
|
71
|
+
sobre una copia con nombre controlado (soffice nombra la salida como la
|
|
72
|
+
entrada) y con un perfil de usuario propio: con el perfil por defecto,
|
|
73
|
+
soffice no arranca si LibreOffice ya está abierto."""
|
|
74
|
+
soffice = buscar_soffice()
|
|
75
|
+
if soffice is None:
|
|
76
|
+
raise RuntimeError(f"{origen.name} necesita LibreOffice, que no está instalado.")
|
|
77
|
+
carpeta.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
# El nombre de la copia acaba como título de hoja cuando soffice importa
|
|
79
|
+
# un ".xls" que en realidad es CSV/HTML (exportaciones de bancos): el
|
|
80
|
+
# del documento, saneado y recortado (cabe de sobra en NAME_MAX), no
|
|
81
|
+
# "entrada" (hallazgo de careo, BAJA, 04/10).
|
|
82
|
+
base = nombre_seguro(origen.stem) or "entrada"
|
|
83
|
+
base = base.encode("utf-8")[:100].decode("utf-8", "ignore").rstrip(" .") or "entrada"
|
|
84
|
+
entrada = carpeta / f"{base}{origen.suffix.lower()}"
|
|
85
|
+
try:
|
|
86
|
+
shutil.copyfile(origen, entrada)
|
|
87
|
+
except OSError as e:
|
|
88
|
+
raise RuntimeError(f"No se pudo leer {origen.name}: {e}") from e
|
|
89
|
+
salida = carpeta / f"{base}.{formato}"
|
|
90
|
+
perfil = (carpeta / "perfil-lo").resolve().as_uri()
|
|
91
|
+
return _ejecutar([soffice, f"-env:UserInstallation={perfil}", "--headless",
|
|
92
|
+
"--convert-to", formato, "--outdir", str(carpeta), str(entrada)],
|
|
93
|
+
origen, salida, "LibreOffice")
|