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.
Files changed (43) hide show
  1. {tablewise-1.1.0 → tablewise-1.1.2}/PKG-INFO +92 -18
  2. {tablewise-1.1.0 → tablewise-1.1.2}/README.md +74 -16
  3. tablewise-1.1.2/motor_office.py +172 -0
  4. tablewise-1.1.2/motor_propio.py +476 -0
  5. tablewise-1.1.2/postpro.py +94 -0
  6. {tablewise-1.1.0 → tablewise-1.1.2}/preconversion.py +41 -0
  7. {tablewise-1.1.0 → tablewise-1.1.2}/pyproject.toml +14 -5
  8. {tablewise-1.1.0 → tablewise-1.1.2}/tablewise.egg-info/PKG-INFO +92 -18
  9. {tablewise-1.1.0 → tablewise-1.1.2}/tablewise.egg-info/SOURCES.txt +10 -1
  10. tablewise-1.1.2/tablewise.egg-info/requires.txt +25 -0
  11. {tablewise-1.1.0 → tablewise-1.1.2}/tablewise.egg-info/top_level.txt +3 -0
  12. {tablewise-1.1.0 → tablewise-1.1.2}/tablewise_app.py +65 -22
  13. tablewise-1.1.2/tablewise_cli.py +401 -0
  14. {tablewise-1.1.0 → tablewise-1.1.2}/tablewise_core.py +122 -3
  15. {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_app_convertir.py +42 -0
  16. tablewise-1.1.2/tests/test_cli.py +355 -0
  17. {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_core.py +69 -2
  18. tablewise-1.1.2/tests/test_motor_office.py +238 -0
  19. tablewise-1.1.2/tests/test_motor_propio.py +365 -0
  20. tablewise-1.1.2/tests/test_motor_propio_escaneados.py +174 -0
  21. tablewise-1.1.2/tests/test_quality.py +57 -0
  22. tablewise-1.1.2/tests/test_sprint_deliver.py +234 -0
  23. tablewise-1.1.2/tests/test_weekly_summary.py +46 -0
  24. tablewise-1.1.0/tablewise.egg-info/requires.txt +0 -11
  25. tablewise-1.1.0/tablewise_cli.py +0 -185
  26. tablewise-1.1.0/tests/test_cli.py +0 -149
  27. {tablewise-1.1.0 → tablewise-1.1.2}/LICENSE +0 -0
  28. {tablewise-1.1.0 → tablewise-1.1.2}/motor_docling.py +0 -0
  29. {tablewise-1.1.0 → tablewise-1.1.2}/motor_excel.py +0 -0
  30. {tablewise-1.1.0 → tablewise-1.1.2}/setup.cfg +0 -0
  31. {tablewise-1.1.0 → tablewise-1.1.2}/tablewise.egg-info/dependency_links.txt +0 -0
  32. {tablewise-1.1.0 → tablewise-1.1.2}/tablewise.egg-info/entry_points.txt +0 -0
  33. {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_cli_motores.py +0 -0
  34. {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_docs.py +0 -0
  35. {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_entradas_externas.py +0 -0
  36. {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_motor_docling.py +0 -0
  37. {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_motor_docling_cli.py +0 -0
  38. {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_motor_docling_convertir.py +0 -0
  39. {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_motor_excel.py +0 -0
  40. {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_preconversion.py +0 -0
  41. {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_preconversion_ejecutables_falsos.py +0 -0
  42. {tablewise-1.1.0 → tablewise-1.1.2}/tests/test_preconversion_errores.py +0 -0
  43. {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.0
4
- Summary: Convierte PDF/Word/Excel/PowerPoint a Markdown con cabecera de procedencia, listo para un RAG
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
+ [![PyPI](https://img.shields.io/pypi/v/tablewise.svg)](https://pypi.org/project/tablewise/)
68
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](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
- - **Desktop app** (Tkinter) — for people who do not live in a terminal.
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 bytes in → same Markdown out.
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 -e . # installs the `tablewise` command
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
- - `.pdf` `.docx` `.pptx` → `motor_docling.py` (Docling, local, no AI)
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` `.ppt` (Office 97-2003) → LibreOffice → `.xlsx`/`.pptx` → `motor_excel.py` / `motor_docling.py`
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 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.
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
- Overage $0.015/unit. Annual −20%. The full contract (quota, DPA, refunds) is in
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
+ [![PyPI](https://img.shields.io/pypi/v/tablewise.svg)](https://pypi.org/project/tablewise/)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](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
- - **Desktop app** (Tkinter) — for people who do not live in a terminal.
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 bytes in → same Markdown out.
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 -e . # installs the `tablewise` command
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
- - `.pdf` `.docx` `.pptx` → `motor_docling.py` (Docling, local, no AI)
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` `.ppt` (Office 97-2003) → LibreOffice → `.xlsx`/`.pptx` → `motor_excel.py` / `motor_docling.py`
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 two own engines are `motor_excel.py` and `motor_docling.py`. A format
95
- without an available engine is refused with an explicit message, never silently
96
- degraded.
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
- Overage $0.015/unit. Annual −20%. The full contract (quota, DPA, refunds) is in
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)")