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.
@@ -0,0 +1,217 @@
1
+ Metadata-Version: 2.4
2
+ Name: tablewise
3
+ Version: 1.1.0
4
+ Summary: Convierte PDF/Word/Excel/PowerPoint a Markdown con cabecera de procedencia, listo para un RAG
5
+ Author: Luis Francisco Palomares Gamarra
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Luis Francisco Palomares Gamarra
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Keywords: markdown,rag,pdf,docx,xlsx,markitdown,docling
29
+ Classifier: License :: OSI Approved :: MIT License
30
+ Classifier: Programming Language :: Python :: 3
31
+ Classifier: Programming Language :: Python :: 3.11
32
+ Classifier: Programming Language :: Python :: 3.12
33
+ Classifier: Programming Language :: Python :: 3.13
34
+ Classifier: Environment :: MacOS X
35
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
36
+ Requires-Python: >=3.11
37
+ Description-Content-Type: text/markdown
38
+ License-File: LICENSE
39
+ Provides-Extra: excel
40
+ Requires-Dist: openpyxl>=3.1; extra == "excel"
41
+ Provides-Extra: docling
42
+ Requires-Dist: docling; extra == "docling"
43
+ Provides-Extra: dev
44
+ Requires-Dist: pytest>=8; extra == "dev"
45
+ Requires-Dist: openpyxl>=3.1; extra == "dev"
46
+ Requires-Dist: PyYAML>=6; extra == "dev"
47
+ Dynamic: license-file
48
+
49
+ # Tablewise
50
+
51
+ **Spreadsheet-grade document conversion to Markdown, with provenance.**
52
+ PDF, Word, Excel and PowerPoint in; clean, citable Markdown out — built for RAG,
53
+ LLM ingestion and audit trails.
54
+
55
+ Every output file starts with a header that tells your retrieval layer where the
56
+ text came from:
57
+
58
+ ```markdown
59
+ ---
60
+ source: "2026-budget.xlsx"
61
+ engine: "excel"
62
+ converted: 2026-10-22
63
+ ---
64
+ ```
65
+
66
+ Three front ends, one decision engine:
67
+
68
+ - **`tablewise` CLI** — one document in, one `.md` out. Scripts, agents, batches.
69
+ - **Desktop app** (Tkinter) — for people who do not live in a terminal.
70
+ - **Hosted API** — `POST /v1/convert`, per-month plans, no install. See
71
+ [PRICING.md](PRICING.md).
72
+
73
+ ## The problem it solves
74
+
75
+ Most converters flatten a financial table into something no model can use. A
76
+ three-level header (`Hotel · 2026 · Real/Budget · Jan..Dec`) comes out as 70
77
+ columns named `Unnamed: 12`, formulas vanish, and a percentage becomes `0.8769`.
78
+ A RAG built on that answers with the wrong number and no way to prove it.
79
+
80
+ Tablewise routes spreadsheets to an engine that reads the sheet as a sheet:
81
+
82
+ | Hotel | 2026 | 2026 | 2026 |
83
+ | | Real | Budget | Last year |
84
+ | | Jan | Feb | Jan |
85
+ | ADR | 120.50 | 110 | 105.25 |
86
+ | Occupancy | 87.69% | | |
87
+
88
+ - Merged cells and multi-level headers preserved as a real Markdown table.
89
+ - Dates as `YYYY-MM-DD`, percentages with their format, `-0.00` never appears.
90
+ - Hidden sheets kept but labelled `(hidden sheet)`.
91
+ - A formula that was never recalculated says `(formula not calculated)` instead
92
+ of an empty cell that a model reads as zero.
93
+ - Deterministic: no AI, no network, same bytes in → same Markdown out.
94
+
95
+ ## Quickstart
96
+
97
+ ```bash
98
+ pip install -e . # installs the `tablewise` command
99
+ tablewise report.pdf # -> ~/md-repo/report.md
100
+ tablewise budget.xlsx -o ~/md-repo -n "2026 budget"
101
+ tablewise contract.docx --dry-run # print the engine command, do not run it
102
+ ```
103
+
104
+ The desktop app:
105
+
106
+ ```bash
107
+ python tablewise_app.py
108
+ ```
109
+
110
+ The API:
111
+
112
+ ```bash
113
+ curl -X POST https://api.tablewise.dev/v1/convert \
114
+ -H "Authorization: Bearer $TABLEWISE_API_KEY" \
115
+ -F "file=@budget.xlsx"
116
+ ```
117
+
118
+ ## Why not just MarkItDown / Docling?
119
+
120
+ They are good engines and Tablewise uses both. The difference is the routing,
121
+ the table fidelity and the provenance — not a new parser.
122
+
123
+ | | MarkItDown | Docling | Tablewise |
124
+ |---|---|---|---|
125
+ | Excel with merged / multi-level header | flattened | partial | read as a sheet |
126
+ | Formula never recalculated | empty cell | empty cell | labelled |
127
+ | Provenance header on every file | no | no | yes |
128
+ | Deterministic, no AI, no network | yes | yes (heavy) | yes |
129
+ | Desktop GUI for non-technical users | no | no | yes |
130
+ | Hosted API with per-month plans and batch | no | no | yes |
131
+
132
+ ## Engine routing
133
+
134
+ Each extension has exactly one owner:
135
+
136
+ - `.xlsx` `.xlsm` `.xltx` `.xltm` → `motor_excel.py` (openpyxl, local, no AI)
137
+ - `.pdf` `.docx` `.pptx` → `motor_docling.py` (Docling, local, no AI)
138
+ - `.doc` `.rtf` `.odt` → `textutil` (macOS) → HTML → `motor_docling.py`
139
+ - `.xls` `.ppt` (Office 97-2003) → LibreOffice → `.xlsx`/`.pptx` → `motor_excel.py` / `motor_docling.py`
140
+ - everything else (CSV, HTML, EPUB, …) → MarkItDown
141
+
142
+ The two own engines are `motor_excel.py` and `motor_docling.py`. A format
143
+ without an available engine is refused with an explicit message, never silently
144
+ degraded.
145
+
146
+ ## CLI reference
147
+
148
+ ```
149
+ tablewise DOCUMENT [-o DIR] [-n NAME] [--python PATH] [--markitdown PATH]
150
+ [--force] [--dry-run] [-q] [--version]
151
+ ```
152
+
153
+ - Never overwrites an existing `.md` without `--force`.
154
+ - Exit codes: `0` ok · `1` the engine produced no file · `2` invalid input.
155
+ - `--python` / `TABLEWISE_PYTHON`: interpreter that carries docling/openpyxl.
156
+ - `--markitdown` / `TABLEWISE_MARKITDOWN`: MarkItDown executable.
157
+
158
+ ## Hosted API
159
+
160
+ The API is the same engine behind HTTP, for teams that cannot install anything:
161
+
162
+ | Plan | Units / month | Price |
163
+ |---|---|---|
164
+ | Free | 50 | $0 |
165
+ | Starter | 1,000 | $19 |
166
+ | Pro | 5,000 | $49 |
167
+ | Business | 25,000 | $149 |
168
+
169
+ One unit = one PDF page, one spreadsheet sheet, one slide, or one document.
170
+ Overage $0.015/unit. Annual −20%. The full contract (quota, DPA, refunds) is in
171
+ [PRICING.md](PRICING.md) and on the website.
172
+
173
+ ## Development
174
+
175
+ Runner: `pytest`, using the engines virtualenv (it carries openpyxl and PyYAML):
176
+
177
+ ```bash
178
+ .venv/bin/python3 -m pytest -q # 432 tests
179
+ .venv/bin/python3 -m pytest server/tests -q # 46 API tests
180
+ ```
181
+
182
+ | File | What it covers |
183
+ |---|---|
184
+ | `tests/test_core.py` | `tablewise_core.py`: extension routing, safe names, engine command (`shlex`, `~`), YAML frontmatter, atomic write, free name |
185
+ | `tests/test_motor_excel.py` | `motor_excel.py` on synthetic books: merged cells, unevaluated formulas, empty rows/columns, dates, hidden sheets, safety ceiling |
186
+ | `tests/test_cli_motores.py` | engine CLI contract by subprocess (exit code + message, never a traceback) |
187
+ | `tests/test_motor_docling.py` | `.doc`/`.rtf`/`.odt` preconversion to HTML with `textutil` (no Docling import; skipped off macOS) |
188
+ | `tests/test_preconversion.py` | LibreOffice (`soffice`): lookup outside PATH and `.xls` → `.xlsx` keeping merges (skipped without LibreOffice) |
189
+ | `tests/test_preconversion_errores.py` | `preconversion.py` with simulated `subprocess.run`: timeout, tool that cannot start, exit 0 without file, error code, own profile |
190
+ | `tests/test_motor_docling_convertir.py` | `motor_docling.convertir` with an injected fake Docling: empty output, temp cleanup, `.ppt` routing, uppercase extensions |
191
+ | `tests/test_entradas_externas.py` | `.xls` path, malformed array formulas, backslashes in the YAML header, non-UTF-8 output, BOM and CRLF |
192
+ | `tests/test_preconversion_ejecutables_falsos.py` | textutil/LibreOffice boundary with fake executables |
193
+ | `tests/test_app_convertir.py` | `App.convert()` with fake tkinter and engine: existing `.md` (cancel / numbered copy / overwrite), failing engine, non-UTF-8 output, partial writes |
194
+ | `tests/test_motor_docling_cli.py` | `motor_docling` CLI with a fake `docling` package |
195
+ | `tests/test_red_sintaxis.py` | regression net: every application file is valid Python and the app imports without opening a window |
196
+ | `tests/test_cli.py` | `tablewise` CLI: same decisions as the app, `--dry-run`, no overwrite without `--force`, exit codes |
197
+ | `tests/test_docs.py` | drift net: this README lists every test file and every routed extension |
198
+
199
+ No test uses real documents.
200
+
201
+ ## Limits and security
202
+
203
+ - Upload limit 25 MB and 2,000 units per request (configurable).
204
+ - Engines run as subprocesses with a timeout; a hung converter cannot take the
205
+ worker down.
206
+ - API keys are stored as SHA-256 hashes and shown once.
207
+ - No document content is logged. See `web/privacy.html` and `web/dpa.html`.
208
+ - macOS-only pieces: `textutil` (`.doc`/`.rtf`/`.odt`) and the Tkinter app.
209
+ LibreOffice is needed for `.xls`/`.ppt`.
210
+
211
+ ## License
212
+
213
+ MIT. See [LICENSE](LICENSE).
214
+
215
+ ---
216
+
217
+ Spanish engineering log, kept for continuity: [README.es.md](README.es.md).
@@ -0,0 +1,12 @@
1
+ motor_docling.py,sha256=vO4dCZRdtDP5lp81oXjr87c2dcgrAiYlJAyYQPO4tT0,3064
2
+ motor_excel.py,sha256=mosa8TOq7tBBNU-eNB59mkyxMbr6w8Dvx5RjMK5n8mQ,22658
3
+ preconversion.py,sha256=uW4yumm1uvJLTPENDVcvd9sLxqkYOLuL1COqBLOD_WQ,4086
4
+ tablewise_app.py,sha256=GG8yeAQ8wTHACzDFieibYlnTUmq3CjsNgoNrQy7QxAs,14394
5
+ tablewise_cli.py,sha256=8JGZRT-dFVoPWn_9OV0x2g8mcwryMypE3N13irEzq-o,7519
6
+ tablewise_core.py,sha256=pEhNKvPt3ZnGRs9hYHKu5H1o4YOk6fbfOsT8z_Is2Co,17613
7
+ tablewise-1.1.0.dist-info/licenses/LICENSE,sha256=eVMFbxicWd3q6ZW5pdQMp_r0CmFyUAwszOotrKm1Bfc,1089
8
+ tablewise-1.1.0.dist-info/METADATA,sha256=SjHw65svOtfV2lQ8RsFiUFRPHvX_2TpYj-YTnUyxQ_0,9322
9
+ tablewise-1.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ tablewise-1.1.0.dist-info/entry_points.txt,sha256=NBBxSTLetAtuZTAqVsH2BGl8gTvKDvSCHjPrWF2d-nQ,49
11
+ tablewise-1.1.0.dist-info/top_level.txt,sha256=bQfEdO7eznTOxxEgK2Mb53fg794IiZjtfdJK1yD8EfY,83
12
+ tablewise-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tablewise = tablewise_cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luis Francisco Palomares Gamarra
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,6 @@
1
+ motor_docling
2
+ motor_excel
3
+ preconversion
4
+ tablewise_app
5
+ tablewise_cli
6
+ tablewise_core
tablewise_app.py ADDED
@@ -0,0 +1,361 @@
1
+ from __future__ import annotations
2
+ import datetime
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ import shutil
7
+ import tempfile
8
+ from pathlib import Path
9
+ import tkinter as tk
10
+ from tkinter import filedialog, messagebox
11
+
12
+ import tablewise_core
13
+ import preconversion
14
+
15
+ HOME = Path.home()
16
+ REPO_DIR = Path(__file__).resolve().parent
17
+ DEFAULT_OUTPUT_DIR = HOME / "md-repo"
18
+ VENV_MARKITDOWN = HOME / "markitdown-env" / "bin" / "markitdown"
19
+
20
+ VENV_TOOLS_PY = REPO_DIR / ".venv" / "bin" / "python3"
21
+
22
+ class App:
23
+ def __init__(self, root: tk.Tk):
24
+ self.root = root
25
+ self.root.title("Documentos a Markdown → Repo")
26
+ self.root.geometry("760x520")
27
+ self.root.configure(bg="#f5f3ef")
28
+
29
+ self.pdf_path = tk.StringVar()
30
+ self.repo_path = tk.StringVar(value=str(DEFAULT_OUTPUT_DIR))
31
+ self.filename = tk.StringVar()
32
+ self.status = tk.StringVar(value="Selecciona un documento y una carpeta destino.")
33
+ # LibreOffice (opcional) habilita .xls/.ppt: se mira una vez al abrir.
34
+ self.hay_soffice = preconversion.buscar_soffice() is not None
35
+
36
+ self.build_ui()
37
+
38
+ def build_ui(self):
39
+ wrap = tk.Frame(self.root, bg="#f5f3ef", padx=24, pady=24)
40
+ wrap.pack(fill="both", expand=True)
41
+
42
+ tk.Label(
43
+ wrap,
44
+ text="Documentos a Markdown → Repo",
45
+ font=("Helvetica", 20, "bold"),
46
+ bg="#f5f3ef",
47
+ fg="#1f2937"
48
+ ).pack(anchor="w")
49
+
50
+ tk.Label(
51
+ wrap,
52
+ text="Mini app local para convertir PDF/Word/Excel/PowerPoint a .md (motor según tipo de documento) y guardarlo en tu repositorio de conocimiento.",
53
+ font=("Helvetica", 11),
54
+ bg="#f5f3ef",
55
+ fg="#4b5563",
56
+ wraplength=680,
57
+ justify="left",
58
+ ).pack(anchor="w", pady=(8, 20))
59
+
60
+ self._field(wrap, "1) Documento de entrada (PDF/Word/Excel/PowerPoint)", self.pdf_path, self.pick_pdf, "Elegir documento")
61
+ self._field(wrap, "2) Carpeta repo destino", self.repo_path, self.pick_repo, "Elegir carpeta")
62
+ self._text_field(wrap, "3) Nombre del .md (sin extensión)", self.filename)
63
+
64
+ btns = tk.Frame(wrap, bg="#f5f3ef")
65
+ btns.pack(fill="x", pady=(18, 10))
66
+
67
+ tk.Button(
68
+ btns,
69
+ text="Convertir y guardar",
70
+ command=self.convert,
71
+ bg="#0f766e",
72
+ fg="white",
73
+ padx=16,
74
+ pady=10
75
+ ).pack(side="left")
76
+
77
+ tk.Button(
78
+ btns,
79
+ text="Abrir carpeta repo",
80
+ command=self.open_repo,
81
+ bg="#e5e7eb",
82
+ fg="#111827",
83
+ padx=16,
84
+ pady=10
85
+ ).pack(side="left", padx=10)
86
+
87
+ tk.Button(
88
+ btns,
89
+ text="Copiar comando CLI",
90
+ command=self.copy_cli,
91
+ bg="#e5e7eb",
92
+ fg="#111827",
93
+ padx=16,
94
+ pady=10
95
+ ).pack(side="left")
96
+
97
+ notes = (
98
+ "Flujo recomendado:\n"
99
+ "- Soporta PDF, Word (.docx/.doc/.rtf/.odt), Excel (.xlsx/.xlsm; .xls con LibreOffice) y PowerPoint (.pptx; .ppt con LibreOffice).\n"
100
+ "- Deja tu repo de Markdown en ~/md-repo o cambia la carpeta aquí.\n"
101
+ "- Enrutamiento automático por tipo (23/08/2026):\n"
102
+ " · .xlsx/.xlsm/.xltx/.xltm → motor Excel propio (openpyxl, sin IA, exacto con celdas combinadas)\n"
103
+ " · .pdf/.docx/.pptx → Docling (local, gratis, reconstruye tablas complejas)\n"
104
+ " · .doc/.rtf/.odt → textutil (macOS) a HTML → Docling\n"
105
+ " · .xls/.ppt (97-2003) → LibreOffice a .xlsx/.pptx → motor Excel / Docling\n"
106
+ " · el resto (HTML, CSV, EPUB...) → MarkItDown\n"
107
+ "- El archivo resultante se guarda en la carpeta repo lista para tu RAG/IA."
108
+ )
109
+
110
+ tk.Label(
111
+ wrap,
112
+ text=notes,
113
+ bg="#eef2f7",
114
+ fg="#374151",
115
+ justify="left",
116
+ anchor="w",
117
+ padx=14,
118
+ pady=14
119
+ ).pack(fill="x", pady=(12, 18))
120
+
121
+ tk.Label(
122
+ wrap,
123
+ textvariable=self.status,
124
+ bg="#f5f3ef",
125
+ fg="#7c2d12",
126
+ justify="left",
127
+ wraplength=690
128
+ ).pack(anchor="w")
129
+
130
+ def _field(self, parent, label, var, command, button_text):
131
+ tk.Label(
132
+ parent,
133
+ text=label,
134
+ font=("Helvetica", 11, "bold"),
135
+ bg="#f5f3ef",
136
+ fg="#111827"
137
+ ).pack(anchor="w", pady=(8, 6))
138
+
139
+ row = tk.Frame(parent, bg="#f5f3ef")
140
+ row.pack(fill="x")
141
+
142
+ tk.Entry(
143
+ row,
144
+ textvariable=var,
145
+ font=("Helvetica", 11),
146
+ relief="solid",
147
+ bd=1
148
+ ).pack(side="left", fill="x", expand=True, ipady=8)
149
+
150
+ tk.Button(
151
+ row,
152
+ text=button_text,
153
+ command=command,
154
+ bg="#d1fae5",
155
+ fg="#065f46",
156
+ padx=12,
157
+ pady=8
158
+ ).pack(side="left", padx=(10, 0))
159
+
160
+ def _text_field(self, parent, label, var):
161
+ tk.Label(
162
+ parent,
163
+ text=label,
164
+ font=("Helvetica", 11, "bold"),
165
+ bg="#f5f3ef",
166
+ fg="#111827"
167
+ ).pack(anchor="w", pady=(16, 6))
168
+
169
+ tk.Entry(
170
+ parent,
171
+ textvariable=var,
172
+ font=("Helvetica", 11),
173
+ relief="solid",
174
+ bd=1
175
+ ).pack(fill="x", ipady=8)
176
+
177
+ def pick_pdf(self):
178
+ # El filtro sale de las mismas extensiones que enrutan (tablewise_core):
179
+ # así no se vuelve a quedar atrás cuando se añade un formato.
180
+ path = filedialog.askopenfilename(filetypes=tablewise_core.tipos_dialogo(self.hay_soffice))
181
+ if path:
182
+ self.pdf_path.set(path)
183
+ if not self.filename.get():
184
+ self.filename.set(Path(path).stem)
185
+
186
+ def pick_repo(self):
187
+ path = filedialog.askdirectory()
188
+ if path:
189
+ self.repo_path.set(path)
190
+
191
+ def get_markitdown_cmd(self):
192
+ if VENV_MARKITDOWN.exists():
193
+ return str(VENV_MARKITDOWN)
194
+ found = shutil.which("markitdown")
195
+ return found
196
+
197
+ def convert(self):
198
+ name = self.filename.get().strip()
199
+ v = tablewise_core.validar_conversion(self.pdf_path.get(), name, self.repo_path.get(),
200
+ hay_soffice=self.hay_soffice)
201
+ if v.error:
202
+ messagebox.showerror(*v.error)
203
+ return
204
+ pdf, repo = v.origen, v.destino.parent
205
+ nombre, out_file = v.nombre, v.destino
206
+ if not name:
207
+ self.filename.set(nombre)
208
+ try:
209
+ repo.mkdir(parents=True, exist_ok=True)
210
+ except OSError as e: # un tramo de la ruta es un fichero, sin permisos...
211
+ messagebox.showerror("Carpeta repo inválida", f"No se pudo crear {repo}:\n\n{e}")
212
+ return
213
+
214
+ # No se comprueba en tablewise_core si `.venv` existe -- se decide aquí
215
+ # para avisar con un mensaje específico en vez de degradar en
216
+ # silencio a MarkItDown (hallazgo de careo, MEDIA, 23/08).
217
+ motor = tablewise_core.elegir_motor(pdf)
218
+ if motor:
219
+ if not VENV_TOOLS_PY.exists():
220
+ messagebox.showerror(
221
+ "Falta el venv de motores",
222
+ f"{pdf.suffix} necesita el motor {motor}, pero no encuentro "
223
+ f"{VENV_TOOLS_PY}. Instálalo con:\n\n"
224
+ f"cd {REPO_DIR}\npython3 -m venv .venv\n"
225
+ ".venv/bin/pip install -r requirements-motores.txt"
226
+ )
227
+ return
228
+ markitdown_cmd = ""
229
+ motor_legible = {"motor_excel": "motor Excel (openpyxl, sin IA)", "motor_docling": "Docling (local, gratis)"}[motor]
230
+ else:
231
+ markitdown_cmd = self.get_markitdown_cmd()
232
+ if not markitdown_cmd:
233
+ messagebox.showerror(
234
+ "MarkItDown no encontrado",
235
+ "No encuentro markitdown. Instálalo en ~/markitdown-env o en tu PATH."
236
+ )
237
+ return
238
+ motor_legible = "MarkItDown"
239
+ # Después de comprobar motores: no preguntar para luego fallar.
240
+ if out_file.exists():
241
+ copia = tablewise_core.ruta_libre(repo, nombre)
242
+ respuesta = messagebox.askyesnocancel(
243
+ "El .md ya existe",
244
+ f"Ya existe {out_file.name} en la carpeta repo.\n\n"
245
+ "Sí → sobrescribirlo\n"
246
+ f"No → guardar como {copia.name}\n"
247
+ "Cancelar → no convertir",
248
+ default=messagebox.NO, # Enter no debe pisar nada
249
+ )
250
+ if respuesta is None:
251
+ return
252
+ if respuesta is False:
253
+ out_file = copia
254
+
255
+ # El motor escribe en un temporal de la misma carpeta y el destino
256
+ # solo se reemplaza tras validar el resultado: con "sobrescribir",
257
+ # un motor que trunca su salida y falla, o que acaba bien sin
258
+ # escribir nada, destruía o daba por convertido el .md retocado a
259
+ # mano (hallazgos de careo, IMPORTANTE, 15/10).
260
+ # Carpeta temporal exclusiva de esta ejecución (no un nombre fijo,
261
+ # que borraba un fichero ajeno o el temporal de otra conversión), y
262
+ # dentro el fichero aún sin crear: así "existe" sigue probando que
263
+ # el motor escribió. Misma carpeta → os.replace atómico.
264
+ try:
265
+ carpeta_temporal = Path(tempfile.mkdtemp(prefix=".convirtiendo-", dir=repo))
266
+ except OSError as e:
267
+ self.status.set(f"No se pudo escribir en {repo}: {e}")
268
+ messagebox.showerror("Carpeta repo inválida", f"No se pudo escribir en {repo}:\n\n{e}")
269
+ return
270
+ temporal = carpeta_temporal / out_file.name
271
+ cmd = tablewise_core.construir_comando(
272
+ pdf, temporal, python_motores=VENV_TOOLS_PY, repo_dir=REPO_DIR,
273
+ markitdown=markitdown_cmd,
274
+ )
275
+
276
+ try:
277
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
278
+ # Código 0 sin fichero (un conversor que no reconoce el formato
279
+ # y "acaba bien"): no se puede anunciar un .md que no existe.
280
+ if not temporal.exists():
281
+ detalle = (result.stderr or result.stdout or "").strip()
282
+ self.status.set(f"{motor_legible} terminó sin generar {out_file.name}.\n{detalle[:1200]}")
283
+ messagebox.showerror(
284
+ "Falló la conversión",
285
+ f"Motor: {motor_legible}\n\nTerminó sin errores pero no generó "
286
+ f"{out_file}.\n\n{detalle[:1500]}",
287
+ )
288
+ return
289
+ msg = f"OK ({motor_legible}). Markdown generado en:\n{out_file}"
290
+ try:
291
+ tablewise_core.anadir_frontmatter(
292
+ temporal, pdf, motor or "markitdown", datetime.date.today()
293
+ )
294
+ except (OSError, UnicodeError) as e:
295
+ # La conversión ya está hecha: sin cabecera sigue sirviendo.
296
+ msg += f"\n\n(No se pudo añadir la cabecera de procedencia: {e})"
297
+ try:
298
+ os.replace(temporal, out_file)
299
+ except OSError as e:
300
+ self.status.set(f"No se pudo guardar {out_file}: {e}")
301
+ messagebox.showerror("Falló la conversión", f"No se pudo guardar {out_file}:\n\n{e}")
302
+ return
303
+ salida = result.stdout.replace(str(temporal), str(out_file))
304
+ if salida.strip():
305
+ msg += f"\n\nSalida:\n{salida[:800]}"
306
+ self.status.set(msg)
307
+ messagebox.showinfo("Conversión completada", msg)
308
+ except subprocess.CalledProcessError as e:
309
+ err = e.stderr or e.stdout or str(e)
310
+ self.status.set(f"Error al convertir con {motor_legible}:\n{err[:1200]}")
311
+ messagebox.showerror("Falló la conversión", f"Motor: {motor_legible}\n\n{err[:1500]}")
312
+ except OSError as e:
313
+ # El ejecutable existe pero no se pudo lanzar (permisos, venv
314
+ # roto, etc.) -- CalledProcessError no cubre esto (hallazgo de
315
+ # careo, MEDIA, 23/08: antes terminaba en traceback crudo).
316
+ self.status.set(f"No se pudo lanzar {motor_legible}: {e}")
317
+ messagebox.showerror("Falló la conversión", f"No se pudo lanzar {motor_legible}:\n\n{e}")
318
+ finally:
319
+ # Solo lo creado por esta ejecución; un fallo al limpiar no debe
320
+ # tapar el mensaje ya mostrado con un traceback.
321
+ shutil.rmtree(carpeta_temporal, ignore_errors=True)
322
+
323
+ def open_repo(self):
324
+ repo = Path(self.repo_path.get()).expanduser()
325
+ repo.mkdir(parents=True, exist_ok=True)
326
+ subprocess.run(["open", str(repo)])
327
+
328
+ def copy_cli(self):
329
+ elegido = self.pdf_path.get().strip()
330
+
331
+ # Mismas comprobaciones que convert(): sin esto se copiaban comandos
332
+ # que pisaban el origen o convertían un "~$x.xlsx". Sin documento
333
+ # elegido se copia una plantilla, sin exigir que exista.
334
+ v = tablewise_core.validar_conversion(elegido or "/ruta/a/tu.pdf", self.filename.get().strip(),
335
+ self.repo_path.get(), hay_soffice=self.hay_soffice,
336
+ comprobar_existe=bool(elegido))
337
+ if v.error:
338
+ self.status.set(f"No se copió el comando: {v.error[1]}")
339
+ messagebox.showerror(*v.error)
340
+ return
341
+
342
+ # Mismo constructor que convert(): el comando copiado es el que
343
+ # ejecutaría la app, con MarkItDown resuelto a ruta absoluta.
344
+ cmd = tablewise_core.construir_comando(
345
+ v.origen, v.destino, python_motores=VENV_TOOLS_PY, repo_dir=REPO_DIR,
346
+ markitdown=self.get_markitdown_cmd() or "markitdown",
347
+ )
348
+
349
+ self.root.clipboard_clear()
350
+ self.root.clipboard_append(tablewise_core.comando_shell(cmd))
351
+ self.status.set("Comando copiado al portapapeles.")
352
+
353
+ def main():
354
+ root = tk.Tk()
355
+ app = App(root)
356
+ root.mainloop()
357
+
358
+ if __name__ == "__main__":
359
+ if sys.platform != "darwin":
360
+ print("Esta mini app está pensada para macOS, pero se puede adaptar fácilmente.")
361
+ main()