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 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")