tablewise 1.1.1__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.1 → tablewise-1.1.2}/PKG-INFO +87 -19
- {tablewise-1.1.1 → 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.1 → tablewise-1.1.2}/preconversion.py +41 -0
- {tablewise-1.1.1 → tablewise-1.1.2}/pyproject.toml +6 -6
- {tablewise-1.1.1 → tablewise-1.1.2}/tablewise.egg-info/PKG-INFO +87 -19
- {tablewise-1.1.1 → 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.1 → tablewise-1.1.2}/tablewise.egg-info/top_level.txt +3 -0
- {tablewise-1.1.1 → tablewise-1.1.2}/tablewise_app.py +65 -22
- tablewise-1.1.2/tablewise_cli.py +401 -0
- {tablewise-1.1.1 → tablewise-1.1.2}/tablewise_core.py +122 -3
- {tablewise-1.1.1 → tablewise-1.1.2}/tests/test_app_convertir.py +42 -0
- tablewise-1.1.2/tests/test_cli.py +355 -0
- {tablewise-1.1.1 → 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.1/tablewise.egg-info/requires.txt +0 -11
- tablewise-1.1.1/tablewise_cli.py +0 -185
- tablewise-1.1.1/tests/test_cli.py +0 -149
- {tablewise-1.1.1 → tablewise-1.1.2}/LICENSE +0 -0
- {tablewise-1.1.1 → tablewise-1.1.2}/motor_docling.py +0 -0
- {tablewise-1.1.1 → tablewise-1.1.2}/motor_excel.py +0 -0
- {tablewise-1.1.1 → tablewise-1.1.2}/setup.cfg +0 -0
- {tablewise-1.1.1 → tablewise-1.1.2}/tablewise.egg-info/dependency_links.txt +0 -0
- {tablewise-1.1.1 → tablewise-1.1.2}/tablewise.egg-info/entry_points.txt +0 -0
- {tablewise-1.1.1 → tablewise-1.1.2}/tests/test_cli_motores.py +0 -0
- {tablewise-1.1.1 → tablewise-1.1.2}/tests/test_docs.py +0 -0
- {tablewise-1.1.1 → tablewise-1.1.2}/tests/test_entradas_externas.py +0 -0
- {tablewise-1.1.1 → tablewise-1.1.2}/tests/test_motor_docling.py +0 -0
- {tablewise-1.1.1 → tablewise-1.1.2}/tests/test_motor_docling_cli.py +0 -0
- {tablewise-1.1.1 → tablewise-1.1.2}/tests/test_motor_docling_convertir.py +0 -0
- {tablewise-1.1.1 → tablewise-1.1.2}/tests/test_motor_excel.py +0 -0
- {tablewise-1.1.1 → tablewise-1.1.2}/tests/test_preconversion.py +0 -0
- {tablewise-1.1.1 → tablewise-1.1.2}/tests/test_preconversion_ejecutables_falsos.py +0 -0
- {tablewise-1.1.1 → tablewise-1.1.2}/tests/test_preconversion_errores.py +0 -0
- {tablewise-1.1.1 → tablewise-1.1.2}/tests/test_red_sintaxis.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tablewise
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.2
|
|
4
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
|
|
@@ -27,8 +27,6 @@ License: MIT License
|
|
|
27
27
|
|
|
28
28
|
Project-URL: Homepage, https://tablewise.dev
|
|
29
29
|
Project-URL: Documentation, https://tablewise.dev/docs
|
|
30
|
-
Project-URL: Repository, https://github.com/lfpalomaresg/tablewise
|
|
31
|
-
Project-URL: Changelog, https://github.com/lfpalomaresg/tablewise/blob/main/CHANGELOG.md
|
|
32
30
|
Keywords: markdown,rag,pdf,docx,xlsx,markitdown,docling
|
|
33
31
|
Classifier: License :: OSI Approved :: MIT License
|
|
34
32
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -44,16 +42,31 @@ Description-Content-Type: text/markdown
|
|
|
44
42
|
License-File: LICENSE
|
|
45
43
|
Provides-Extra: excel
|
|
46
44
|
Requires-Dist: openpyxl>=3.1; extra == "excel"
|
|
45
|
+
Requires-Dist: xlrd>=2.0; extra == "excel"
|
|
47
46
|
Provides-Extra: docling
|
|
48
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"
|
|
49
57
|
Provides-Extra: dev
|
|
50
58
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
51
59
|
Requires-Dist: openpyxl>=3.1; extra == "dev"
|
|
52
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"
|
|
53
63
|
Dynamic: license-file
|
|
54
64
|
|
|
55
65
|
# Tablewise
|
|
56
66
|
|
|
67
|
+
[](https://pypi.org/project/tablewise/)
|
|
68
|
+
[](LICENSE)
|
|
69
|
+
|
|
57
70
|
**Spreadsheet-grade document conversion to Markdown, with provenance.**
|
|
58
71
|
PDF, Word, Excel and PowerPoint in; clean, citable Markdown out — built for RAG,
|
|
59
72
|
LLM ingestion and audit trails.
|
|
@@ -72,10 +85,23 @@ converted: 2026-10-22
|
|
|
72
85
|
Three front ends, one decision engine:
|
|
73
86
|
|
|
74
87
|
- **`tablewise` CLI** — one document in, one `.md` out. Scripts, agents, batches.
|
|
75
|
-
- **
|
|
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).
|
|
76
91
|
- **Hosted API** — `POST /v1/convert`, per-month plans, no install. See
|
|
77
92
|
[PRICING.md](PRICING.md).
|
|
78
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
|
+
|
|
79
105
|
## The problem it solves
|
|
80
106
|
|
|
81
107
|
Most converters flatten a financial table into something no model can use. A
|
|
@@ -96,23 +122,17 @@ Tablewise routes spreadsheets to an engine that reads the sheet as a sheet:
|
|
|
96
122
|
- Hidden sheets kept but labelled `(hidden sheet)`.
|
|
97
123
|
- A formula that was never recalculated says `(formula not calculated)` instead
|
|
98
124
|
of an empty cell that a model reads as zero.
|
|
99
|
-
- Deterministic: no AI, no network, same
|
|
125
|
+
- Deterministic: no AI, no network, same input and same content (the provenance date is the only variable).
|
|
100
126
|
|
|
101
127
|
## Quickstart
|
|
102
128
|
|
|
103
129
|
```bash
|
|
104
|
-
pip install
|
|
130
|
+
pip install "tablewise[excel]" # from PyPI; add [docling] or [propio] for PDF/DOCX tables
|
|
105
131
|
tablewise report.pdf # -> ~/md-repo/report.md
|
|
106
132
|
tablewise budget.xlsx -o ~/md-repo -n "2026 budget"
|
|
107
133
|
tablewise contract.docx --dry-run # print the engine command, do not run it
|
|
108
134
|
```
|
|
109
135
|
|
|
110
|
-
The desktop app:
|
|
111
|
-
|
|
112
|
-
```bash
|
|
113
|
-
python tablewise_app.py
|
|
114
|
-
```
|
|
115
|
-
|
|
116
136
|
The API:
|
|
117
137
|
|
|
118
138
|
```bash
|
|
@@ -121,6 +141,14 @@ curl -X POST https://api.tablewise.dev/v1/convert \
|
|
|
121
141
|
-F "file=@budget.xlsx"
|
|
122
142
|
```
|
|
123
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
|
+
|
|
124
152
|
## Why not just MarkItDown / Docling?
|
|
125
153
|
|
|
126
154
|
They are good engines and Tablewise uses both. The difference is the routing,
|
|
@@ -132,7 +160,6 @@ the table fidelity and the provenance — not a new parser.
|
|
|
132
160
|
| Formula never recalculated | empty cell | empty cell | labelled |
|
|
133
161
|
| Provenance header on every file | no | no | yes |
|
|
134
162
|
| Deterministic, no AI, no network | yes | yes (heavy) | yes |
|
|
135
|
-
| Desktop GUI for non-technical users | no | no | yes |
|
|
136
163
|
| Hosted API with per-month plans and batch | no | no | yes |
|
|
137
164
|
|
|
138
165
|
## Engine routing
|
|
@@ -140,14 +167,44 @@ the table fidelity and the provenance — not a new parser.
|
|
|
140
167
|
Each extension has exactly one owner:
|
|
141
168
|
|
|
142
169
|
- `.xlsx` `.xlsm` `.xltx` `.xltm` → `motor_excel.py` (openpyxl, local, no AI)
|
|
143
|
-
- `.
|
|
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)
|
|
144
172
|
- `.doc` `.rtf` `.odt` → `textutil` (macOS) → HTML → `motor_docling.py`
|
|
145
|
-
- `.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`)
|
|
146
175
|
- everything else (CSV, HTML, EPUB, …) → MarkItDown
|
|
147
176
|
|
|
148
|
-
The
|
|
149
|
-
|
|
150
|
-
|
|
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.
|
|
151
208
|
|
|
152
209
|
## CLI reference
|
|
153
210
|
|
|
@@ -173,9 +230,14 @@ The API is the same engine behind HTTP, for teams that cannot install anything:
|
|
|
173
230
|
| Business | 25,000 | $149 |
|
|
174
231
|
|
|
175
232
|
One unit = one PDF page, one spreadsheet sheet, one slide, or one document.
|
|
176
|
-
|
|
233
|
+
No overage billing yet (upgrade the plan). Annual billing is not available yet. The full contract (quota, DPA, refunds) is in
|
|
177
234
|
[PRICING.md](PRICING.md) and on the website.
|
|
178
235
|
|
|
236
|
+
## Operations
|
|
237
|
+
|
|
238
|
+
Deploying, billing, support, backups and incident playbook:
|
|
239
|
+
[docs/OPERATIONS.md](docs/OPERATIONS.md).
|
|
240
|
+
|
|
179
241
|
## Development
|
|
180
242
|
|
|
181
243
|
Runner: `pytest`, using the engines virtualenv (it carries openpyxl and PyYAML):
|
|
@@ -194,6 +256,10 @@ Runner: `pytest`, using the engines virtualenv (it carries openpyxl and PyYAML):
|
|
|
194
256
|
| `tests/test_preconversion.py` | LibreOffice (`soffice`): lookup outside PATH and `.xls` → `.xlsx` keeping merges (skipped without LibreOffice) |
|
|
195
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 |
|
|
196
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 |
|
|
197
263
|
| `tests/test_entradas_externas.py` | `.xls` path, malformed array formulas, backslashes in the YAML header, non-UTF-8 output, BOM and CRLF |
|
|
198
264
|
| `tests/test_preconversion_ejecutables_falsos.py` | textutil/LibreOffice boundary with fake executables |
|
|
199
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 |
|
|
@@ -201,6 +267,8 @@ Runner: `pytest`, using the engines virtualenv (it carries openpyxl and PyYAML):
|
|
|
201
267
|
| `tests/test_red_sintaxis.py` | regression net: every application file is valid Python and the app imports without opening a window |
|
|
202
268
|
| `tests/test_cli.py` | `tablewise` CLI: same decisions as the app, `--dry-run`, no overwrite without `--force`, exit codes |
|
|
203
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 |
|
|
204
272
|
|
|
205
273
|
No test uses real documents.
|
|
206
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)")
|