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
|
@@ -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,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.
|
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()
|