tablewise 1.1.0__tar.gz → 1.1.2__tar.gz
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.
- {tablewise-1.1.0 → tablewise-1.1.2}/PKG-INFO +92 -18
- {tablewise-1.1.0 → tablewise-1.1.2}/README.md +74 -16
- tablewise-1.1.2/motor_office.py +172 -0
- tablewise-1.1.2/motor_propio.py +476 -0
- tablewise-1.1.2/postpro.py +94 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/preconversion.py +41 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/pyproject.toml +14 -5
- {tablewise-1.1.0 → tablewise-1.1.2}/tablewise.egg-info/PKG-INFO +92 -18
- {tablewise-1.1.0 → tablewise-1.1.2}/tablewise.egg-info/SOURCES.txt +10 -1
- tablewise-1.1.2/tablewise.egg-info/requires.txt +25 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/tablewise.egg-info/top_level.txt +3 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/tablewise_app.py +65 -22
- tablewise-1.1.2/tablewise_cli.py +401 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/tablewise_core.py +122 -3
- {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_app_convertir.py +42 -0
- tablewise-1.1.2/tests/test_cli.py +355 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_core.py +69 -2
- tablewise-1.1.2/tests/test_motor_office.py +238 -0
- tablewise-1.1.2/tests/test_motor_propio.py +365 -0
- tablewise-1.1.2/tests/test_motor_propio_escaneados.py +174 -0
- tablewise-1.1.2/tests/test_quality.py +57 -0
- tablewise-1.1.2/tests/test_sprint_deliver.py +234 -0
- tablewise-1.1.2/tests/test_weekly_summary.py +46 -0
- tablewise-1.1.0/tablewise.egg-info/requires.txt +0 -11
- tablewise-1.1.0/tablewise_cli.py +0 -185
- tablewise-1.1.0/tests/test_cli.py +0 -149
- {tablewise-1.1.0 → tablewise-1.1.2}/LICENSE +0 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/motor_docling.py +0 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/motor_excel.py +0 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/setup.cfg +0 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/tablewise.egg-info/dependency_links.txt +0 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/tablewise.egg-info/entry_points.txt +0 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_cli_motores.py +0 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_docs.py +0 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_entradas_externas.py +0 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_motor_docling.py +0 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_motor_docling_cli.py +0 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_motor_docling_convertir.py +0 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_motor_excel.py +0 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_preconversion.py +0 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_preconversion_ejecutables_falsos.py +0 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_preconversion_errores.py +0 -0
- {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_red_sintaxis.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tablewise
|
|
3
|
-
Version: 1.1.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 1.1.2
|
|
4
|
+
Summary: Spreadsheet-grade document to Markdown with provenance, ready for RAG and LLM ingestion
|
|
5
5
|
Author: Luis Francisco Palomares Gamarra
|
|
6
6
|
License: MIT License
|
|
7
7
|
|
|
@@ -25,6 +25,8 @@ License: MIT License
|
|
|
25
25
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
26
|
SOFTWARE.
|
|
27
27
|
|
|
28
|
+
Project-URL: Homepage, https://tablewise.dev
|
|
29
|
+
Project-URL: Documentation, https://tablewise.dev/docs
|
|
28
30
|
Keywords: markdown,rag,pdf,docx,xlsx,markitdown,docling
|
|
29
31
|
Classifier: License :: OSI Approved :: MIT License
|
|
30
32
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -32,22 +34,39 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
32
34
|
Classifier: Programming Language :: Python :: 3.12
|
|
33
35
|
Classifier: Programming Language :: Python :: 3.13
|
|
34
36
|
Classifier: Environment :: MacOS X
|
|
37
|
+
Classifier: Environment :: Win32 (MS Windows)
|
|
38
|
+
Classifier: Environment :: X11 Applications
|
|
35
39
|
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
36
40
|
Requires-Python: >=3.11
|
|
37
41
|
Description-Content-Type: text/markdown
|
|
38
42
|
License-File: LICENSE
|
|
39
43
|
Provides-Extra: excel
|
|
40
44
|
Requires-Dist: openpyxl>=3.1; extra == "excel"
|
|
45
|
+
Requires-Dist: xlrd>=2.0; extra == "excel"
|
|
41
46
|
Provides-Extra: docling
|
|
42
47
|
Requires-Dist: docling; extra == "docling"
|
|
48
|
+
Provides-Extra: office
|
|
49
|
+
Requires-Dist: python-docx>=1.1; extra == "office"
|
|
50
|
+
Requires-Dist: python-pptx>=0.6.23; extra == "office"
|
|
51
|
+
Provides-Extra: propio
|
|
52
|
+
Requires-Dist: onnxruntime>=1.24; extra == "propio"
|
|
53
|
+
Requires-Dist: pymupdf>=1.24; extra == "propio"
|
|
54
|
+
Requires-Dist: numpy>=1.26; extra == "propio"
|
|
55
|
+
Requires-Dist: pillow>=10; extra == "propio"
|
|
56
|
+
Requires-Dist: rapidocr<4,>=3.9; extra == "propio"
|
|
43
57
|
Provides-Extra: dev
|
|
44
58
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
45
59
|
Requires-Dist: openpyxl>=3.1; extra == "dev"
|
|
46
60
|
Requires-Dist: PyYAML>=6; extra == "dev"
|
|
61
|
+
Requires-Dist: python-docx>=1.1; extra == "dev"
|
|
62
|
+
Requires-Dist: python-pptx>=0.6.23; extra == "dev"
|
|
47
63
|
Dynamic: license-file
|
|
48
64
|
|
|
49
65
|
# Tablewise
|
|
50
66
|
|
|
67
|
+
[](https://pypi.org/project/tablewise/)
|
|
68
|
+
[](LICENSE)
|
|
69
|
+
|
|
51
70
|
**Spreadsheet-grade document conversion to Markdown, with provenance.**
|
|
52
71
|
PDF, Word, Excel and PowerPoint in; clean, citable Markdown out — built for RAG,
|
|
53
72
|
LLM ingestion and audit trails.
|
|
@@ -66,10 +85,23 @@ converted: 2026-10-22
|
|
|
66
85
|
Three front ends, one decision engine:
|
|
67
86
|
|
|
68
87
|
- **`tablewise` CLI** — one document in, one `.md` out. Scripts, agents, batches.
|
|
69
|
-
- **
|
|
88
|
+
- **Done-for-you ingestion sprint** — we convert your corpus and check it against a
|
|
89
|
+
sample you approve: https://tablewise.dev/#done-for-you (a Tkinter desktop app ships
|
|
90
|
+
in this repository but is not packaged).
|
|
70
91
|
- **Hosted API** — `POST /v1/convert`, per-month plans, no install. See
|
|
71
92
|
[PRICING.md](PRICING.md).
|
|
72
93
|
|
|
94
|
+
## Documentation
|
|
95
|
+
|
|
96
|
+
- [User guide](docs/USER_GUIDE.md) — CLI, desktop app and API, step by step.
|
|
97
|
+
- [Architecture](docs/ARCHITECTURE.md) — components, data model, failure modes.
|
|
98
|
+
- [Maintenance](docs/MAINTENANCE.md) — cadence, automation, incidents.
|
|
99
|
+
- [Business model](docs/BUSINESS_MODEL.md) — public repo + paid product (open-core).
|
|
100
|
+
- [Decisions](docs/DECISIONS.md) — short ADR log (why SQLite, monthly only, engine fallback).
|
|
101
|
+
- [Retainer](docs/RETAINER.md) — how the monthly ingestion service works, end to end.
|
|
102
|
+
- [Operations](docs/OPERATIONS.md) · [Pricing](PRICING.md) · [Security](SECURITY.md) ·
|
|
103
|
+
[Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md).
|
|
104
|
+
|
|
73
105
|
## The problem it solves
|
|
74
106
|
|
|
75
107
|
Most converters flatten a financial table into something no model can use. A
|
|
@@ -90,23 +122,17 @@ Tablewise routes spreadsheets to an engine that reads the sheet as a sheet:
|
|
|
90
122
|
- Hidden sheets kept but labelled `(hidden sheet)`.
|
|
91
123
|
- A formula that was never recalculated says `(formula not calculated)` instead
|
|
92
124
|
of an empty cell that a model reads as zero.
|
|
93
|
-
- Deterministic: no AI, no network, same
|
|
125
|
+
- Deterministic: no AI, no network, same input and same content (the provenance date is the only variable).
|
|
94
126
|
|
|
95
127
|
## Quickstart
|
|
96
128
|
|
|
97
129
|
```bash
|
|
98
|
-
pip install
|
|
130
|
+
pip install "tablewise[excel]" # from PyPI; add [docling] or [propio] for PDF/DOCX tables
|
|
99
131
|
tablewise report.pdf # -> ~/md-repo/report.md
|
|
100
132
|
tablewise budget.xlsx -o ~/md-repo -n "2026 budget"
|
|
101
133
|
tablewise contract.docx --dry-run # print the engine command, do not run it
|
|
102
134
|
```
|
|
103
135
|
|
|
104
|
-
The desktop app:
|
|
105
|
-
|
|
106
|
-
```bash
|
|
107
|
-
python tablewise_app.py
|
|
108
|
-
```
|
|
109
|
-
|
|
110
136
|
The API:
|
|
111
137
|
|
|
112
138
|
```bash
|
|
@@ -115,6 +141,14 @@ curl -X POST https://api.tablewise.dev/v1/convert \
|
|
|
115
141
|
-F "file=@budget.xlsx"
|
|
116
142
|
```
|
|
117
143
|
|
|
144
|
+
## Benchmark in one line
|
|
145
|
+
|
|
146
|
+
On the synthetic P&L of the public benchmark (three-level header, merged cells,
|
|
147
|
+
formulas, percentages), MarkItDown produces 11 `Unnamed` columns and 41 empty cells;
|
|
148
|
+
Tablewise produces none, keeps the 3 percentages and labels the 3 unevaluated
|
|
149
|
+
formulas. Reproduce it with `python bench/run_benchmark.py` or download the five
|
|
150
|
+
workbooks from https://tablewise.dev/benchmark.
|
|
151
|
+
|
|
118
152
|
## Why not just MarkItDown / Docling?
|
|
119
153
|
|
|
120
154
|
They are good engines and Tablewise uses both. The difference is the routing,
|
|
@@ -126,7 +160,6 @@ the table fidelity and the provenance — not a new parser.
|
|
|
126
160
|
| Formula never recalculated | empty cell | empty cell | labelled |
|
|
127
161
|
| Provenance header on every file | no | no | yes |
|
|
128
162
|
| Deterministic, no AI, no network | yes | yes (heavy) | yes |
|
|
129
|
-
| Desktop GUI for non-technical users | no | no | yes |
|
|
130
163
|
| Hosted API with per-month plans and batch | no | no | yes |
|
|
131
164
|
|
|
132
165
|
## Engine routing
|
|
@@ -134,14 +167,44 @@ the table fidelity and the provenance — not a new parser.
|
|
|
134
167
|
Each extension has exactly one owner:
|
|
135
168
|
|
|
136
169
|
- `.xlsx` `.xlsm` `.xltx` `.xltm` → `motor_excel.py` (openpyxl, local, no AI)
|
|
137
|
-
- `.
|
|
170
|
+
- `.docx` `.pptx` → `motor_office.py` (python-docx/python-pptx, local, no AI) when the `office` extra is installed; otherwise `motor_docling.py`
|
|
171
|
+
- `.pdf` → `motor_propio.py` when its models are installed; otherwise `motor_docling.py` (Docling, local, no AI) — **optional** in the hosted image (`INSTALL_DOCLING=0` by default)
|
|
138
172
|
- `.doc` `.rtf` `.odt` → `textutil` (macOS) → HTML → `motor_docling.py`
|
|
139
|
-
- `.xls`
|
|
173
|
+
- `.xls` (Office 97-2003) → LibreOffice → `.xlsx` → `motor_excel.py`
|
|
174
|
+
- `.ppt` (Office 97-2003) → LibreOffice → `.pptx` inside `motor_docling.py` (legacy path; `motor_office` does not read `.ppt`)
|
|
140
175
|
- everything else (CSV, HTML, EPUB, …) → MarkItDown
|
|
141
176
|
|
|
142
|
-
The
|
|
143
|
-
|
|
144
|
-
|
|
177
|
+
The own engines are `motor_excel.py`, `motor_office.py`, `motor_docling.py` and `motor_propio.py`.
|
|
178
|
+
Known limits of `motor_office`: headers/footers are not extracted, text boxes
|
|
179
|
+
are appended at the end (visual order is not guaranteed), and a nested table is
|
|
180
|
+
emitted after its parent table. Legacy `.doc/.rtf/.odt/.ppt` stay on Docling.
|
|
181
|
+
|
|
182
|
+
### PDF tables (own engine)
|
|
183
|
+
|
|
184
|
+
PDFs use the own **`motor_propio.py`** engine (Microsoft Table Transformer +
|
|
185
|
+
RapidOCR, ONNX, CPU) instead of Docling **only when its models are installed**:
|
|
186
|
+
it is faster (~0.9 s/doc digital, ~2.6 s scanned) and more accurate on tables
|
|
187
|
+
(0.94 exact rows on the blind control and 0.92 on the real set; v1: 0.92 / 0.90).
|
|
188
|
+
Full per-set table: [tablewise.dev/benchmark#pdf-engine](https://tablewise.dev/benchmark#pdf-engine).
|
|
189
|
+
Since `modelos-v2` (c07) it runs two detectors (`det` v1 + `det2` c06) and picks
|
|
190
|
+
the better box per page; the structure model is unchanged. The models are
|
|
191
|
+
published at
|
|
192
|
+
[`tablewise-modelos`](https://github.com/lfpalomaresg/tablewise-modelos/releases/tag/modelos-v2);
|
|
193
|
+
install them with:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
curl -fsSL -O https://github.com/lfpalomaresg/tablewise-modelos/releases/download/modelos-v2/SHA256SUMS
|
|
197
|
+
python3 scripts/modelos_propio.py \
|
|
198
|
+
--url https://github.com/lfpalomaresg/tablewise-modelos/releases/download/modelos-v2/ \
|
|
199
|
+
--sha256 SHA256SUMS
|
|
200
|
+
python3 scripts/modelos_propio.py --verificar # det2: sí
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
They go to `~/.cache/tablewise/modelos` (or `TABLEWISE_PROPIO_MODELS`); ~347 MB,
|
|
204
|
+
never in the repo or the wheel. `PROPIO_DET2=0` disables the second detector.
|
|
205
|
+
Without the models, PDFs stay on Docling — no
|
|
206
|
+
change in behaviour. A format without an available engine is refused with an
|
|
207
|
+
explicit message, never silently degraded.
|
|
145
208
|
|
|
146
209
|
## CLI reference
|
|
147
210
|
|
|
@@ -167,9 +230,14 @@ The API is the same engine behind HTTP, for teams that cannot install anything:
|
|
|
167
230
|
| Business | 25,000 | $149 |
|
|
168
231
|
|
|
169
232
|
One unit = one PDF page, one spreadsheet sheet, one slide, or one document.
|
|
170
|
-
|
|
233
|
+
No overage billing yet (upgrade the plan). Annual billing is not available yet. The full contract (quota, DPA, refunds) is in
|
|
171
234
|
[PRICING.md](PRICING.md) and on the website.
|
|
172
235
|
|
|
236
|
+
## Operations
|
|
237
|
+
|
|
238
|
+
Deploying, billing, support, backups and incident playbook:
|
|
239
|
+
[docs/OPERATIONS.md](docs/OPERATIONS.md).
|
|
240
|
+
|
|
173
241
|
## Development
|
|
174
242
|
|
|
175
243
|
Runner: `pytest`, using the engines virtualenv (it carries openpyxl and PyYAML):
|
|
@@ -188,6 +256,10 @@ Runner: `pytest`, using the engines virtualenv (it carries openpyxl and PyYAML):
|
|
|
188
256
|
| `tests/test_preconversion.py` | LibreOffice (`soffice`): lookup outside PATH and `.xls` → `.xlsx` keeping merges (skipped without LibreOffice) |
|
|
189
257
|
| `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
258
|
| `tests/test_motor_docling_convertir.py` | `motor_docling.convertir` with an injected fake Docling: empty output, temp cleanup, `.ppt` routing, uppercase extensions |
|
|
259
|
+
| `tests/test_motor_propio.py` | own PDF engine: model resolution, safe PDF→own/Docling routing, table repairs, CLI error contract |
|
|
260
|
+
| `tests/test_motor_propio_escaneados.py` | own PDF engine on scans: OCR without line-orientation classifier, lone dash in empty value cells |
|
|
261
|
+
| `tests/test_quality.py` | synthetic quality KPI: table/row metric and table-text filtering without loading the ONNX models |
|
|
262
|
+
| `tests/test_motor_office.py` | light Office engine: `.docx`/`.pptx` paragraphs and tables without Docling, CLI contract |
|
|
191
263
|
| `tests/test_entradas_externas.py` | `.xls` path, malformed array formulas, backslashes in the YAML header, non-UTF-8 output, BOM and CRLF |
|
|
192
264
|
| `tests/test_preconversion_ejecutables_falsos.py` | textutil/LibreOffice boundary with fake executables |
|
|
193
265
|
| `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 |
|
|
@@ -195,6 +267,8 @@ Runner: `pytest`, using the engines virtualenv (it carries openpyxl and PyYAML):
|
|
|
195
267
|
| `tests/test_red_sintaxis.py` | regression net: every application file is valid Python and the app imports without opening a window |
|
|
196
268
|
| `tests/test_cli.py` | `tablewise` CLI: same decisions as the app, `--dry-run`, no overwrite without `--force`, exit codes |
|
|
197
269
|
| `tests/test_docs.py` | drift net: this README lists every test file and every routed extension |
|
|
270
|
+
| `tests/test_sprint_deliver.py` | `scripts/sprint_deliver.py`: delivery package, manifest hashes, failed files listed, determinism check |
|
|
271
|
+
| `tests/test_weekly_summary.py` | `scripts/weekly_summary.py`: the 5-number weekly funnel summary |
|
|
198
272
|
|
|
199
273
|
No test uses real documents.
|
|
200
274
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# Tablewise
|
|
2
2
|
|
|
3
|
+
[](https://pypi.org/project/tablewise/)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
3
6
|
**Spreadsheet-grade document conversion to Markdown, with provenance.**
|
|
4
7
|
PDF, Word, Excel and PowerPoint in; clean, citable Markdown out — built for RAG,
|
|
5
8
|
LLM ingestion and audit trails.
|
|
@@ -18,10 +21,23 @@ converted: 2026-10-22
|
|
|
18
21
|
Three front ends, one decision engine:
|
|
19
22
|
|
|
20
23
|
- **`tablewise` CLI** — one document in, one `.md` out. Scripts, agents, batches.
|
|
21
|
-
- **
|
|
24
|
+
- **Done-for-you ingestion sprint** — we convert your corpus and check it against a
|
|
25
|
+
sample you approve: https://tablewise.dev/#done-for-you (a Tkinter desktop app ships
|
|
26
|
+
in this repository but is not packaged).
|
|
22
27
|
- **Hosted API** — `POST /v1/convert`, per-month plans, no install. See
|
|
23
28
|
[PRICING.md](PRICING.md).
|
|
24
29
|
|
|
30
|
+
## Documentation
|
|
31
|
+
|
|
32
|
+
- [User guide](docs/USER_GUIDE.md) — CLI, desktop app and API, step by step.
|
|
33
|
+
- [Architecture](docs/ARCHITECTURE.md) — components, data model, failure modes.
|
|
34
|
+
- [Maintenance](docs/MAINTENANCE.md) — cadence, automation, incidents.
|
|
35
|
+
- [Business model](docs/BUSINESS_MODEL.md) — public repo + paid product (open-core).
|
|
36
|
+
- [Decisions](docs/DECISIONS.md) — short ADR log (why SQLite, monthly only, engine fallback).
|
|
37
|
+
- [Retainer](docs/RETAINER.md) — how the monthly ingestion service works, end to end.
|
|
38
|
+
- [Operations](docs/OPERATIONS.md) · [Pricing](PRICING.md) · [Security](SECURITY.md) ·
|
|
39
|
+
[Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md).
|
|
40
|
+
|
|
25
41
|
## The problem it solves
|
|
26
42
|
|
|
27
43
|
Most converters flatten a financial table into something no model can use. A
|
|
@@ -42,23 +58,17 @@ Tablewise routes spreadsheets to an engine that reads the sheet as a sheet:
|
|
|
42
58
|
- Hidden sheets kept but labelled `(hidden sheet)`.
|
|
43
59
|
- A formula that was never recalculated says `(formula not calculated)` instead
|
|
44
60
|
of an empty cell that a model reads as zero.
|
|
45
|
-
- Deterministic: no AI, no network, same
|
|
61
|
+
- Deterministic: no AI, no network, same input and same content (the provenance date is the only variable).
|
|
46
62
|
|
|
47
63
|
## Quickstart
|
|
48
64
|
|
|
49
65
|
```bash
|
|
50
|
-
pip install
|
|
66
|
+
pip install "tablewise[excel]" # from PyPI; add [docling] or [propio] for PDF/DOCX tables
|
|
51
67
|
tablewise report.pdf # -> ~/md-repo/report.md
|
|
52
68
|
tablewise budget.xlsx -o ~/md-repo -n "2026 budget"
|
|
53
69
|
tablewise contract.docx --dry-run # print the engine command, do not run it
|
|
54
70
|
```
|
|
55
71
|
|
|
56
|
-
The desktop app:
|
|
57
|
-
|
|
58
|
-
```bash
|
|
59
|
-
python tablewise_app.py
|
|
60
|
-
```
|
|
61
|
-
|
|
62
72
|
The API:
|
|
63
73
|
|
|
64
74
|
```bash
|
|
@@ -67,6 +77,14 @@ curl -X POST https://api.tablewise.dev/v1/convert \
|
|
|
67
77
|
-F "file=@budget.xlsx"
|
|
68
78
|
```
|
|
69
79
|
|
|
80
|
+
## Benchmark in one line
|
|
81
|
+
|
|
82
|
+
On the synthetic P&L of the public benchmark (three-level header, merged cells,
|
|
83
|
+
formulas, percentages), MarkItDown produces 11 `Unnamed` columns and 41 empty cells;
|
|
84
|
+
Tablewise produces none, keeps the 3 percentages and labels the 3 unevaluated
|
|
85
|
+
formulas. Reproduce it with `python bench/run_benchmark.py` or download the five
|
|
86
|
+
workbooks from https://tablewise.dev/benchmark.
|
|
87
|
+
|
|
70
88
|
## Why not just MarkItDown / Docling?
|
|
71
89
|
|
|
72
90
|
They are good engines and Tablewise uses both. The difference is the routing,
|
|
@@ -78,7 +96,6 @@ the table fidelity and the provenance — not a new parser.
|
|
|
78
96
|
| Formula never recalculated | empty cell | empty cell | labelled |
|
|
79
97
|
| Provenance header on every file | no | no | yes |
|
|
80
98
|
| Deterministic, no AI, no network | yes | yes (heavy) | yes |
|
|
81
|
-
| Desktop GUI for non-technical users | no | no | yes |
|
|
82
99
|
| Hosted API with per-month plans and batch | no | no | yes |
|
|
83
100
|
|
|
84
101
|
## Engine routing
|
|
@@ -86,14 +103,44 @@ the table fidelity and the provenance — not a new parser.
|
|
|
86
103
|
Each extension has exactly one owner:
|
|
87
104
|
|
|
88
105
|
- `.xlsx` `.xlsm` `.xltx` `.xltm` → `motor_excel.py` (openpyxl, local, no AI)
|
|
89
|
-
- `.
|
|
106
|
+
- `.docx` `.pptx` → `motor_office.py` (python-docx/python-pptx, local, no AI) when the `office` extra is installed; otherwise `motor_docling.py`
|
|
107
|
+
- `.pdf` → `motor_propio.py` when its models are installed; otherwise `motor_docling.py` (Docling, local, no AI) — **optional** in the hosted image (`INSTALL_DOCLING=0` by default)
|
|
90
108
|
- `.doc` `.rtf` `.odt` → `textutil` (macOS) → HTML → `motor_docling.py`
|
|
91
|
-
- `.xls`
|
|
109
|
+
- `.xls` (Office 97-2003) → LibreOffice → `.xlsx` → `motor_excel.py`
|
|
110
|
+
- `.ppt` (Office 97-2003) → LibreOffice → `.pptx` inside `motor_docling.py` (legacy path; `motor_office` does not read `.ppt`)
|
|
92
111
|
- everything else (CSV, HTML, EPUB, …) → MarkItDown
|
|
93
112
|
|
|
94
|
-
The
|
|
95
|
-
|
|
96
|
-
|
|
113
|
+
The own engines are `motor_excel.py`, `motor_office.py`, `motor_docling.py` and `motor_propio.py`.
|
|
114
|
+
Known limits of `motor_office`: headers/footers are not extracted, text boxes
|
|
115
|
+
are appended at the end (visual order is not guaranteed), and a nested table is
|
|
116
|
+
emitted after its parent table. Legacy `.doc/.rtf/.odt/.ppt` stay on Docling.
|
|
117
|
+
|
|
118
|
+
### PDF tables (own engine)
|
|
119
|
+
|
|
120
|
+
PDFs use the own **`motor_propio.py`** engine (Microsoft Table Transformer +
|
|
121
|
+
RapidOCR, ONNX, CPU) instead of Docling **only when its models are installed**:
|
|
122
|
+
it is faster (~0.9 s/doc digital, ~2.6 s scanned) and more accurate on tables
|
|
123
|
+
(0.94 exact rows on the blind control and 0.92 on the real set; v1: 0.92 / 0.90).
|
|
124
|
+
Full per-set table: [tablewise.dev/benchmark#pdf-engine](https://tablewise.dev/benchmark#pdf-engine).
|
|
125
|
+
Since `modelos-v2` (c07) it runs two detectors (`det` v1 + `det2` c06) and picks
|
|
126
|
+
the better box per page; the structure model is unchanged. The models are
|
|
127
|
+
published at
|
|
128
|
+
[`tablewise-modelos`](https://github.com/lfpalomaresg/tablewise-modelos/releases/tag/modelos-v2);
|
|
129
|
+
install them with:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
curl -fsSL -O https://github.com/lfpalomaresg/tablewise-modelos/releases/download/modelos-v2/SHA256SUMS
|
|
133
|
+
python3 scripts/modelos_propio.py \
|
|
134
|
+
--url https://github.com/lfpalomaresg/tablewise-modelos/releases/download/modelos-v2/ \
|
|
135
|
+
--sha256 SHA256SUMS
|
|
136
|
+
python3 scripts/modelos_propio.py --verificar # det2: sí
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
They go to `~/.cache/tablewise/modelos` (or `TABLEWISE_PROPIO_MODELS`); ~347 MB,
|
|
140
|
+
never in the repo or the wheel. `PROPIO_DET2=0` disables the second detector.
|
|
141
|
+
Without the models, PDFs stay on Docling — no
|
|
142
|
+
change in behaviour. A format without an available engine is refused with an
|
|
143
|
+
explicit message, never silently degraded.
|
|
97
144
|
|
|
98
145
|
## CLI reference
|
|
99
146
|
|
|
@@ -119,9 +166,14 @@ The API is the same engine behind HTTP, for teams that cannot install anything:
|
|
|
119
166
|
| Business | 25,000 | $149 |
|
|
120
167
|
|
|
121
168
|
One unit = one PDF page, one spreadsheet sheet, one slide, or one document.
|
|
122
|
-
|
|
169
|
+
No overage billing yet (upgrade the plan). Annual billing is not available yet. The full contract (quota, DPA, refunds) is in
|
|
123
170
|
[PRICING.md](PRICING.md) and on the website.
|
|
124
171
|
|
|
172
|
+
## Operations
|
|
173
|
+
|
|
174
|
+
Deploying, billing, support, backups and incident playbook:
|
|
175
|
+
[docs/OPERATIONS.md](docs/OPERATIONS.md).
|
|
176
|
+
|
|
125
177
|
## Development
|
|
126
178
|
|
|
127
179
|
Runner: `pytest`, using the engines virtualenv (it carries openpyxl and PyYAML):
|
|
@@ -140,6 +192,10 @@ Runner: `pytest`, using the engines virtualenv (it carries openpyxl and PyYAML):
|
|
|
140
192
|
| `tests/test_preconversion.py` | LibreOffice (`soffice`): lookup outside PATH and `.xls` → `.xlsx` keeping merges (skipped without LibreOffice) |
|
|
141
193
|
| `tests/test_preconversion_errores.py` | `preconversion.py` with simulated `subprocess.run`: timeout, tool that cannot start, exit 0 without file, error code, own profile |
|
|
142
194
|
| `tests/test_motor_docling_convertir.py` | `motor_docling.convertir` with an injected fake Docling: empty output, temp cleanup, `.ppt` routing, uppercase extensions |
|
|
195
|
+
| `tests/test_motor_propio.py` | own PDF engine: model resolution, safe PDF→own/Docling routing, table repairs, CLI error contract |
|
|
196
|
+
| `tests/test_motor_propio_escaneados.py` | own PDF engine on scans: OCR without line-orientation classifier, lone dash in empty value cells |
|
|
197
|
+
| `tests/test_quality.py` | synthetic quality KPI: table/row metric and table-text filtering without loading the ONNX models |
|
|
198
|
+
| `tests/test_motor_office.py` | light Office engine: `.docx`/`.pptx` paragraphs and tables without Docling, CLI contract |
|
|
143
199
|
| `tests/test_entradas_externas.py` | `.xls` path, malformed array formulas, backslashes in the YAML header, non-UTF-8 output, BOM and CRLF |
|
|
144
200
|
| `tests/test_preconversion_ejecutables_falsos.py` | textutil/LibreOffice boundary with fake executables |
|
|
145
201
|
| `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 |
|
|
@@ -147,6 +203,8 @@ Runner: `pytest`, using the engines virtualenv (it carries openpyxl and PyYAML):
|
|
|
147
203
|
| `tests/test_red_sintaxis.py` | regression net: every application file is valid Python and the app imports without opening a window |
|
|
148
204
|
| `tests/test_cli.py` | `tablewise` CLI: same decisions as the app, `--dry-run`, no overwrite without `--force`, exit codes |
|
|
149
205
|
| `tests/test_docs.py` | drift net: this README lists every test file and every routed extension |
|
|
206
|
+
| `tests/test_sprint_deliver.py` | `scripts/sprint_deliver.py`: delivery package, manifest hashes, failed files listed, determinism check |
|
|
207
|
+
| `tests/test_weekly_summary.py` | `scripts/weekly_summary.py`: the 5-number weekly funnel summary |
|
|
150
208
|
|
|
151
209
|
No test uses real documents.
|
|
152
210
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Conversor ligero de Word (.docx) y PowerPoint (.pptx) a Markdown.
|
|
2
|
+
|
|
3
|
+
Lee la estructura nativa del OOXML con `python-docx` / `python-pptx` (MIT), en
|
|
4
|
+
vez de cargar Docling + torch (~3 GB) solo para reconstruir tablas que ya están
|
|
5
|
+
etiquetadas como tablas en el documento. No usa IA ni modelos: es determinista,
|
|
6
|
+
rápido y conserva el orden párrafo/tabla del cuerpo.
|
|
7
|
+
|
|
8
|
+
Los formatos legacy (.doc/.rtf/.odt/.ppt) siguen en Docling (o en LibreOffice
|
|
9
|
+
antes de Docling): aquí no se adivina un binario de 1997.
|
|
10
|
+
|
|
11
|
+
CLI: python motor_office.py <origen.docx|.pptx> <destino.md>
|
|
12
|
+
Librería: from motor_office import convertir
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from postpro import _render
|
|
20
|
+
from tablewise_core import escribir_atomico, misma_ruta
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConversionVacia(Exception):
|
|
24
|
+
"""El documento no produjo contenido."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _heading(paragraph) -> str:
|
|
28
|
+
estilo = (getattr(paragraph.style, "name", "") or "").lower()
|
|
29
|
+
texto = paragraph.text.strip()
|
|
30
|
+
if not texto:
|
|
31
|
+
return ""
|
|
32
|
+
if estilo == "title":
|
|
33
|
+
return f"# {texto}"
|
|
34
|
+
if estilo.startswith("heading"):
|
|
35
|
+
digits = "".join(ch for ch in estilo if ch.isdigit())
|
|
36
|
+
nivel = max(1, min(6, int(digits) if digits else 1))
|
|
37
|
+
return f"{'#' * nivel} {texto}"
|
|
38
|
+
return texto
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _docx(origen: Path) -> str:
|
|
42
|
+
from docx import Document
|
|
43
|
+
from docx.oxml.ns import qn
|
|
44
|
+
from docx.table import Table
|
|
45
|
+
from docx.text.paragraph import Paragraph
|
|
46
|
+
|
|
47
|
+
doc = Document(str(origen))
|
|
48
|
+
partes: list[str] = []
|
|
49
|
+
|
|
50
|
+
vistos_tablas: set = set()
|
|
51
|
+
|
|
52
|
+
def add_tabla(tabla) -> None:
|
|
53
|
+
# `walk` y `celda.tables` pueden llegar a la misma tabla por caminos
|
|
54
|
+
# distintos (sdt, tabla anidada): se emite una sola vez.
|
|
55
|
+
if tabla._tbl in vistos_tablas:
|
|
56
|
+
return
|
|
57
|
+
vistos_tablas.add(tabla._tbl)
|
|
58
|
+
filas = [[celda.text for celda in fila.cells] for fila in tabla.rows]
|
|
59
|
+
if filas and any(any(c.strip() for c in fila) for fila in filas):
|
|
60
|
+
partes.append(_render(filas))
|
|
61
|
+
# Una tabla anidada en una celda no aparece en cell.text: se emite
|
|
62
|
+
# después de la exterior, en el mismo bloque, para no perderla.
|
|
63
|
+
# `row.cells` repite la celda combinada una vez por columna del grid:
|
|
64
|
+
# sin deduplicar por el XML subyacente, su tabla anidada saldría dos veces.
|
|
65
|
+
vistos_celdas: set = set()
|
|
66
|
+
for fila in tabla.rows:
|
|
67
|
+
for celda in fila.cells:
|
|
68
|
+
tc = celda._tc
|
|
69
|
+
if tc in vistos_celdas:
|
|
70
|
+
continue
|
|
71
|
+
vistos_celdas.add(tc)
|
|
72
|
+
for anidada in celda.tables:
|
|
73
|
+
add_tabla(anidada)
|
|
74
|
+
# Un content control dentro de una celda esconde su tabla y su
|
|
75
|
+
# texto de cell.text/tables. Se recorre por hijos (no con
|
|
76
|
+
# tc.iter, que bajaría dos veces por sdts anidados) y solo
|
|
77
|
+
# desde el sdt: el texto directo de la celda ya está en la tabla.
|
|
78
|
+
walk(tc, solo_sdt=True)
|
|
79
|
+
|
|
80
|
+
def add_parrafo(parrafo) -> None:
|
|
81
|
+
texto = _heading(parrafo)
|
|
82
|
+
if texto:
|
|
83
|
+
partes.append(texto)
|
|
84
|
+
|
|
85
|
+
def walk(parent, *, solo_sdt: bool = False) -> None:
|
|
86
|
+
for child in parent.iterchildren():
|
|
87
|
+
if child.tag == qn("w:sdt"):
|
|
88
|
+
contenido = child.find(qn("w:sdtContent"))
|
|
89
|
+
if contenido is not None:
|
|
90
|
+
walk(contenido)
|
|
91
|
+
elif not solo_sdt and child.tag == qn("w:p"):
|
|
92
|
+
add_parrafo(Paragraph(child, doc))
|
|
93
|
+
elif not solo_sdt and child.tag == qn("w:tbl"):
|
|
94
|
+
add_tabla(Table(child, doc))
|
|
95
|
+
|
|
96
|
+
walk(doc.element.body)
|
|
97
|
+
# Contenido de cuadros de texto: cuelga de un run, no del body, así que
|
|
98
|
+
# p.text no lo ve. Se añade al final para no perderlo (orden no garantizado).
|
|
99
|
+
for caja in doc.element.body.iter(qn("w:txbxContent")):
|
|
100
|
+
for parrafo in caja.iter(qn("w:p")):
|
|
101
|
+
add_parrafo(Paragraph(parrafo, doc))
|
|
102
|
+
return "\n\n".join(partes)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _pptx(origen: Path) -> str:
|
|
106
|
+
from pptx import Presentation
|
|
107
|
+
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
108
|
+
|
|
109
|
+
prs = Presentation(str(origen))
|
|
110
|
+
partes: list[str] = []
|
|
111
|
+
|
|
112
|
+
def render_shape(shape):
|
|
113
|
+
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
|
|
114
|
+
for hijo in shape.shapes:
|
|
115
|
+
render_shape(hijo)
|
|
116
|
+
return
|
|
117
|
+
if getattr(shape, "has_table", False):
|
|
118
|
+
filas = [[celda.text for celda in fila.cells] for fila in shape.table.rows]
|
|
119
|
+
if filas and any(any(c.strip() for c in fila) for fila in filas):
|
|
120
|
+
partes.append(_render(filas))
|
|
121
|
+
elif getattr(shape, "has_text_frame", False):
|
|
122
|
+
texto = shape.text_frame.text.strip()
|
|
123
|
+
if texto:
|
|
124
|
+
partes.append(texto)
|
|
125
|
+
|
|
126
|
+
for slide in prs.slides:
|
|
127
|
+
titulo = slide.shapes.title
|
|
128
|
+
if titulo is not None and getattr(titulo, "has_text_frame", False):
|
|
129
|
+
texto = titulo.text_frame.text.strip()
|
|
130
|
+
if texto:
|
|
131
|
+
partes.append(f"# {texto}")
|
|
132
|
+
for shape in slide.shapes:
|
|
133
|
+
if titulo is not None and shape.shape_id == titulo.shape_id:
|
|
134
|
+
continue
|
|
135
|
+
render_shape(shape)
|
|
136
|
+
return "\n\n".join(partes)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def convertir(origen: Path) -> str:
|
|
140
|
+
origen = Path(origen)
|
|
141
|
+
ext = origen.suffix.lower()
|
|
142
|
+
if ext == ".docx":
|
|
143
|
+
md = _docx(origen)
|
|
144
|
+
elif ext == ".pptx":
|
|
145
|
+
md = _pptx(origen)
|
|
146
|
+
else:
|
|
147
|
+
raise ValueError(f"motor_office solo convierte .docx/.pptx, no {ext or '(sin extensión)'}")
|
|
148
|
+
if not md.strip():
|
|
149
|
+
raise ConversionVacia(f"motor_office no produjo contenido para {origen.name}.")
|
|
150
|
+
return md
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
if len(sys.argv) < 3:
|
|
155
|
+
sys.exit("Uso: python motor_office.py <origen.docx|.pptx> <destino.md>")
|
|
156
|
+
origen = Path(sys.argv[1]).expanduser()
|
|
157
|
+
destino = Path(sys.argv[2]).expanduser()
|
|
158
|
+
if not origen.exists():
|
|
159
|
+
sys.exit(f"No existe: {origen}")
|
|
160
|
+
if misma_ruta(origen, destino):
|
|
161
|
+
sys.exit(f"El destino es el propio documento de origen ({origen.name}): no se sobrescribe.")
|
|
162
|
+
try:
|
|
163
|
+
md = convertir(origen)
|
|
164
|
+
except ValueError as e:
|
|
165
|
+
sys.exit(f"motor_office: {e}")
|
|
166
|
+
except Exception as e:
|
|
167
|
+
sys.exit(f"motor_office falló con {origen.name}: {e}")
|
|
168
|
+
try:
|
|
169
|
+
escribir_atomico(destino, md)
|
|
170
|
+
except (OSError, UnicodeError) as e:
|
|
171
|
+
sys.exit(f"No se pudo escribir {destino}: {e}")
|
|
172
|
+
print(f"Guardado en {destino} ({len(md)} caracteres)")
|