tabella-docs 2.0.4__tar.gz → 2.0.5__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.
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/PKG-INFO +11 -10
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/README.md +10 -9
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/pyproject.toml +1 -1
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/tabella/__init__.py +30 -1
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/tabella/convert.py +70 -17
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/tabella/read.py +29 -42
- tabella_docs-2.0.5/tabella/toolspec.py +238 -0
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/tabella_docs.egg-info/PKG-INFO +11 -10
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/tabella_docs.egg-info/SOURCES.txt +5 -1
- tabella_docs-2.0.5/tests/test_convert_pdf_input.py +80 -0
- tabella_docs-2.0.5/tests/test_mcp_handshake.py +42 -0
- tabella_docs-2.0.5/tests/test_version_sync.py +45 -0
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/LICENSE +0 -0
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/setup.cfg +0 -0
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/tabella/bootstrap.py +0 -0
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/tabella/create.py +0 -0
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/tabella/utils.py +0 -0
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/tabella_docs.egg-info/dependency_links.txt +0 -0
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/tabella_docs.egg-info/requires.txt +0 -0
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/tabella_docs.egg-info/top_level.txt +0 -0
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/tests/test_bootstrap_paths.py +0 -0
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/tests/test_ensure_venv_win.py +0 -0
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/tests/test_odt_table.py +0 -0
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/tests/test_read_ocr_fallback.py +0 -0
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/tests/test_security_gates.py +0 -0
- {tabella_docs-2.0.4 → tabella_docs-2.0.5}/tests/test_text_section_alias.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tabella-docs
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.5
|
|
4
4
|
Summary: Portable document lifecycle suite — create (.docx/.xlsx/.pptx/.pdf/.odt/.ods/.odp), convert (pandoc), and read (anydoc + LiteParse OCR) documents. Agent Plugins v1 compatible.
|
|
5
5
|
Author-email: D Yiapanis <d@yiapanis.co>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -142,9 +142,10 @@ sections, sheets, slides, tables, images, and page breaks.
|
|
|
142
142
|
### Convert
|
|
143
143
|
|
|
144
144
|
Format conversion via pandoc + LaTeX (page size, margins, headers/footers
|
|
145
|
-
configurable). **Pandoc 3.1
|
|
146
|
-
`doc_convert` passes your `from`/`to` through, so all of them work. The
|
|
147
|
-
|
|
145
|
+
configurable). **Pandoc 3.1.11 reports 44 input and 65 output formats** —
|
|
146
|
+
`doc_convert` passes your `from`/`to` through, so all of them work. (The counts
|
|
147
|
+
move between pandoc minors; run `pandoc --list-input-formats` to check yours.)
|
|
148
|
+
The everyday set:
|
|
148
149
|
|
|
149
150
|
| Format | In | Out | Notes |
|
|
150
151
|
|---|---|---|---|
|
|
@@ -152,13 +153,13 @@ everyday set:
|
|
|
152
153
|
| HTML | ✅ | ✅ | html4/html5, chunkedhtml |
|
|
153
154
|
| DOCX (Word) | ✅ | ✅ | |
|
|
154
155
|
| ODT | ✅ | ✅ | + `opendocument` |
|
|
155
|
-
| PDF | ✅ | ✅ | in: text
|
|
156
|
+
| PDF | ✅ | ✅ | in: pandoc cannot read PDF, so the text layer is extracted first (OCR when the page is a scan) and the result is converted; out: needs `pdflatex`/`xelatex` (texlive) — engine auto-selected |
|
|
156
157
|
| LaTeX (`.tex`) | ✅ | ✅ | incl. beamer slides out |
|
|
157
158
|
| RST | ✅ | ✅ | |
|
|
158
159
|
| EPUB | ✅ | ✅ | out: epub2/epub3 |
|
|
159
160
|
| CSV / TSV | ✅ | ✅ | out via markdown/AST route |
|
|
160
161
|
| RTF | ✅ | ✅ | |
|
|
161
|
-
| Plain text (`.txt`) | ✅ | ✅ | |
|
|
162
|
+
| Plain text (`.txt`) | ✅ | ✅ | read as markdown — pandoc has no `plain` *reader*, so headings and lists in a `.txt` survive |
|
|
162
163
|
| Org-mode, MediaWiki, Jira wiki, Textile, DocBook, FB2, OPML, IPynb, JATS | ✅ | ✅ | writers for most |
|
|
163
164
|
| Typst / man / ms / texinfo / AsciiDoc | ✅ | ✅ | typst in+out |
|
|
164
165
|
| Presentations | — | ✅ | beamer, revealjs, slidy, s5, dzslides (from any reader, Markdown especially) |
|
|
@@ -242,7 +243,7 @@ brew install cairo pango gdk-pixbuf
|
|
|
242
243
|
|
|
243
244
|
### Windows
|
|
244
245
|
|
|
245
|
-
tabella runs natively on Windows 10/11 (64-bit, Python 3.
|
|
246
|
+
tabella runs natively on Windows 10/11 (64-bit, Python 3.11+); all five MCP
|
|
246
247
|
tools are exercised on `windows-latest` in CI. Native dependencies, installed
|
|
247
248
|
per feature:
|
|
248
249
|
|
|
@@ -291,7 +292,7 @@ required. The bootstrap runs once; subsequent startups use the existing venv.
|
|
|
291
292
|
### Create a PDF from HTML/CSS
|
|
292
293
|
|
|
293
294
|
```python
|
|
294
|
-
from
|
|
295
|
+
from tabella.create import create_document
|
|
295
296
|
|
|
296
297
|
result = create_document(
|
|
297
298
|
format="pdf",
|
|
@@ -304,7 +305,7 @@ result = create_document(
|
|
|
304
305
|
### Read a DOCX file
|
|
305
306
|
|
|
306
307
|
```python
|
|
307
|
-
from
|
|
308
|
+
from tabella.read import read
|
|
308
309
|
|
|
309
310
|
text = read("/path/to/document.docx")
|
|
310
311
|
# Returns GitHub-Flavored Markdown
|
|
@@ -313,7 +314,7 @@ text = read("/path/to/document.docx")
|
|
|
313
314
|
### Convert Markdown to PDF
|
|
314
315
|
|
|
315
316
|
```python
|
|
316
|
-
from
|
|
317
|
+
from tabella.convert import doc_convert
|
|
317
318
|
|
|
318
319
|
result = doc_convert(
|
|
319
320
|
input_path="/path/to/input.md",
|
|
@@ -93,9 +93,10 @@ sections, sheets, slides, tables, images, and page breaks.
|
|
|
93
93
|
### Convert
|
|
94
94
|
|
|
95
95
|
Format conversion via pandoc + LaTeX (page size, margins, headers/footers
|
|
96
|
-
configurable). **Pandoc 3.1
|
|
97
|
-
`doc_convert` passes your `from`/`to` through, so all of them work. The
|
|
98
|
-
|
|
96
|
+
configurable). **Pandoc 3.1.11 reports 44 input and 65 output formats** —
|
|
97
|
+
`doc_convert` passes your `from`/`to` through, so all of them work. (The counts
|
|
98
|
+
move between pandoc minors; run `pandoc --list-input-formats` to check yours.)
|
|
99
|
+
The everyday set:
|
|
99
100
|
|
|
100
101
|
| Format | In | Out | Notes |
|
|
101
102
|
|---|---|---|---|
|
|
@@ -103,13 +104,13 @@ everyday set:
|
|
|
103
104
|
| HTML | ✅ | ✅ | html4/html5, chunkedhtml |
|
|
104
105
|
| DOCX (Word) | ✅ | ✅ | |
|
|
105
106
|
| ODT | ✅ | ✅ | + `opendocument` |
|
|
106
|
-
| PDF | ✅ | ✅ | in: text
|
|
107
|
+
| PDF | ✅ | ✅ | in: pandoc cannot read PDF, so the text layer is extracted first (OCR when the page is a scan) and the result is converted; out: needs `pdflatex`/`xelatex` (texlive) — engine auto-selected |
|
|
107
108
|
| LaTeX (`.tex`) | ✅ | ✅ | incl. beamer slides out |
|
|
108
109
|
| RST | ✅ | ✅ | |
|
|
109
110
|
| EPUB | ✅ | ✅ | out: epub2/epub3 |
|
|
110
111
|
| CSV / TSV | ✅ | ✅ | out via markdown/AST route |
|
|
111
112
|
| RTF | ✅ | ✅ | |
|
|
112
|
-
| Plain text (`.txt`) | ✅ | ✅ | |
|
|
113
|
+
| Plain text (`.txt`) | ✅ | ✅ | read as markdown — pandoc has no `plain` *reader*, so headings and lists in a `.txt` survive |
|
|
113
114
|
| Org-mode, MediaWiki, Jira wiki, Textile, DocBook, FB2, OPML, IPynb, JATS | ✅ | ✅ | writers for most |
|
|
114
115
|
| Typst / man / ms / texinfo / AsciiDoc | ✅ | ✅ | typst in+out |
|
|
115
116
|
| Presentations | — | ✅ | beamer, revealjs, slidy, s5, dzslides (from any reader, Markdown especially) |
|
|
@@ -193,7 +194,7 @@ brew install cairo pango gdk-pixbuf
|
|
|
193
194
|
|
|
194
195
|
### Windows
|
|
195
196
|
|
|
196
|
-
tabella runs natively on Windows 10/11 (64-bit, Python 3.
|
|
197
|
+
tabella runs natively on Windows 10/11 (64-bit, Python 3.11+); all five MCP
|
|
197
198
|
tools are exercised on `windows-latest` in CI. Native dependencies, installed
|
|
198
199
|
per feature:
|
|
199
200
|
|
|
@@ -242,7 +243,7 @@ required. The bootstrap runs once; subsequent startups use the existing venv.
|
|
|
242
243
|
### Create a PDF from HTML/CSS
|
|
243
244
|
|
|
244
245
|
```python
|
|
245
|
-
from
|
|
246
|
+
from tabella.create import create_document
|
|
246
247
|
|
|
247
248
|
result = create_document(
|
|
248
249
|
format="pdf",
|
|
@@ -255,7 +256,7 @@ result = create_document(
|
|
|
255
256
|
### Read a DOCX file
|
|
256
257
|
|
|
257
258
|
```python
|
|
258
|
-
from
|
|
259
|
+
from tabella.read import read
|
|
259
260
|
|
|
260
261
|
text = read("/path/to/document.docx")
|
|
261
262
|
# Returns GitHub-Flavored Markdown
|
|
@@ -264,7 +265,7 @@ text = read("/path/to/document.docx")
|
|
|
264
265
|
### Convert Markdown to PDF
|
|
265
266
|
|
|
266
267
|
```python
|
|
267
|
-
from
|
|
268
|
+
from tabella.convert import doc_convert
|
|
268
269
|
|
|
269
270
|
result = doc_convert(
|
|
270
271
|
input_path="/path/to/input.md",
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tabella-docs"
|
|
7
|
-
version = "2.0.
|
|
7
|
+
version = "2.0.5"
|
|
8
8
|
description = "Portable document lifecycle suite — create (.docx/.xlsx/.pptx/.pdf/.odt/.ods/.odp), convert (pandoc), and read (anydoc + LiteParse OCR) documents. Agent Plugins v1 compatible."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -13,7 +13,36 @@ from pathlib import Path
|
|
|
13
13
|
|
|
14
14
|
from .bootstrap import resolve_venv_python
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
|
|
17
|
+
def _read_version() -> str:
|
|
18
|
+
"""The package version, from installed metadata or pyproject.toml.
|
|
19
|
+
|
|
20
|
+
One source: pyproject.toml. Installed distributions read it back from
|
|
21
|
+
their metadata (so a wheel cannot disagree with itself); a checkout or a
|
|
22
|
+
plugin directory parses the file beside the package. plugin.json carries
|
|
23
|
+
its own literal because a manifest must, and tests/test_version_sync.py
|
|
24
|
+
fails when the two disagree.
|
|
25
|
+
"""
|
|
26
|
+
# Source tree first: when this file sits next to a pyproject.toml we are
|
|
27
|
+
# being imported from a checkout, and that file is the version being
|
|
28
|
+
# edited — installed metadata would still say the previous release.
|
|
29
|
+
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
|
|
30
|
+
try:
|
|
31
|
+
for line in pyproject.read_text(encoding="utf-8").splitlines():
|
|
32
|
+
if line.startswith("version = "):
|
|
33
|
+
return line.split("=", 1)[1].strip().strip('"')
|
|
34
|
+
except OSError:
|
|
35
|
+
pass
|
|
36
|
+
# Installed from a wheel/sdist: the built metadata is authoritative.
|
|
37
|
+
try:
|
|
38
|
+
from importlib.metadata import version
|
|
39
|
+
|
|
40
|
+
return version("tabella-docs")
|
|
41
|
+
except Exception:
|
|
42
|
+
return "0+unknown"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__version__ = _read_version()
|
|
17
46
|
|
|
18
47
|
# ── Configuration ─────────────────────────────────────────────
|
|
19
48
|
|
|
@@ -10,6 +10,7 @@ import json
|
|
|
10
10
|
import logging
|
|
11
11
|
import shutil
|
|
12
12
|
import subprocess
|
|
13
|
+
import tempfile
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
from typing import Optional
|
|
15
16
|
|
|
@@ -27,6 +28,44 @@ def _check_deps(needs_tex: bool = False) -> Optional[str]:
|
|
|
27
28
|
return None
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
def _extract_pdf(src: Path) -> str:
|
|
32
|
+
"""Extract a PDF's text through the read lane (anydoc, then OCR).
|
|
33
|
+
|
|
34
|
+
pandoc has no PDF reader — it converts *to* PDF only. Routing through
|
|
35
|
+
read() means a text layer is used when present and a scanned page is
|
|
36
|
+
OCR'd when not, so `doc_convert(x.pdf, "docx")` behaves like the user
|
|
37
|
+
expects instead of failing inside pandoc.
|
|
38
|
+
"""
|
|
39
|
+
from .read import read
|
|
40
|
+
|
|
41
|
+
return read(str(src))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _validate_extra_args(extra_args: Optional[list]) -> list:
|
|
45
|
+
"""Strict allowlist for pandoc flags.
|
|
46
|
+
|
|
47
|
+
pandoc can execute code via --filter/--lua-filter/--pdf-engine, so only
|
|
48
|
+
formatting flags survive (catalog review #2026-10-16, finding 1). Runs
|
|
49
|
+
before the dependency check so a hostile flag is rejected as invalid,
|
|
50
|
+
not masked by "pandoc is not installed".
|
|
51
|
+
"""
|
|
52
|
+
if not extra_args:
|
|
53
|
+
return []
|
|
54
|
+
allowed = {"--toc", "--number-sections"}
|
|
55
|
+
checked = []
|
|
56
|
+
for a in extra_args:
|
|
57
|
+
if a in allowed:
|
|
58
|
+
checked.append(a)
|
|
59
|
+
elif re.match(r"^-V\S*=", a):
|
|
60
|
+
checked.append(a)
|
|
61
|
+
else:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"unsupported pandoc argument {a!r}: extra_args allows only "
|
|
64
|
+
"--toc, --number-sections and -V <key>=<value>"
|
|
65
|
+
)
|
|
66
|
+
return checked
|
|
67
|
+
|
|
68
|
+
|
|
30
69
|
def _pdf_engine() -> str:
|
|
31
70
|
"""Return preferred LaTeX engine."""
|
|
32
71
|
if shutil.which("xelatex"):
|
|
@@ -70,12 +109,34 @@ def doc_convert(
|
|
|
70
109
|
tgt_fmt = to_format.lower()
|
|
71
110
|
_check_format(src_fmt, "from_format")
|
|
72
111
|
_check_format(tgt_fmt, "to_format")
|
|
112
|
+
extra = _validate_extra_args(extra_args)
|
|
73
113
|
|
|
74
114
|
# pandoc is always required; a TeX engine only for pdf output
|
|
75
115
|
dep_err = _check_deps(tgt_fmt == "pdf")
|
|
76
116
|
if dep_err:
|
|
77
117
|
return {"success": False, "error": dep_err}
|
|
78
118
|
|
|
119
|
+
# pandoc converts to PDF, never from it: extract the text first, then
|
|
120
|
+
# convert the extracted markdown. Scanned pages come back OCR'd, so this
|
|
121
|
+
# works where a bare `pandoc -f pdf` cannot exist at all.
|
|
122
|
+
pandoc_src, pandoc_fmt, tmp_md = str(src), src_fmt, None
|
|
123
|
+
if src_fmt == "pdf":
|
|
124
|
+
try:
|
|
125
|
+
text = _extract_pdf(src)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
return {
|
|
128
|
+
"success": False,
|
|
129
|
+
"error": f"could not extract text from PDF: {type(e).__name__}: {e}",
|
|
130
|
+
}
|
|
131
|
+
if not text.strip():
|
|
132
|
+
return {"success": False, "error": f"PDF has no extractable text: {src}"}
|
|
133
|
+
with tempfile.NamedTemporaryFile(
|
|
134
|
+
"w", suffix=".md", delete=False, encoding="utf-8"
|
|
135
|
+
) as fh:
|
|
136
|
+
fh.write(text)
|
|
137
|
+
tmp_md = fh.name
|
|
138
|
+
pandoc_src, pandoc_fmt = tmp_md, "markdown"
|
|
139
|
+
|
|
79
140
|
# Determine output path
|
|
80
141
|
if output_path:
|
|
81
142
|
out_file = Path(output_path).expanduser().resolve()
|
|
@@ -94,25 +155,11 @@ def doc_convert(
|
|
|
94
155
|
)
|
|
95
156
|
|
|
96
157
|
# Build pandoc command
|
|
97
|
-
cmd = ["pandoc",
|
|
158
|
+
cmd = ["pandoc", pandoc_src, "-o", str(out_file), "-f", pandoc_fmt, "-t", tgt_fmt]
|
|
98
159
|
if tgt_fmt == "pdf":
|
|
99
160
|
engine = _pdf_engine()
|
|
100
161
|
cmd.extend(["--pdf-engine", engine])
|
|
101
|
-
|
|
102
|
-
# strict allowlist — pandoc can execute arbitrary code via --filter/
|
|
103
|
-
# --lua-filter/--pdf-engine, so only formatting flags are honored
|
|
104
|
-
# (catalog review #2026-10-16, finding 1).
|
|
105
|
-
allowed = {"--toc", "--number-sections"}
|
|
106
|
-
for a in extra_args:
|
|
107
|
-
if a in allowed:
|
|
108
|
-
cmd.append(a)
|
|
109
|
-
elif re.match(r"^-V\S*=", a):
|
|
110
|
-
cmd.append(a)
|
|
111
|
-
else:
|
|
112
|
-
raise ValueError(
|
|
113
|
-
f"unsupported pandoc argument {a!r}: extra_args allows only "
|
|
114
|
-
"--toc, --number-sections and -V <key>=<value>"
|
|
115
|
-
)
|
|
162
|
+
cmd.extend(extra)
|
|
116
163
|
|
|
117
164
|
try:
|
|
118
165
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
|
@@ -125,6 +172,9 @@ def doc_convert(
|
|
|
125
172
|
return {"success": False, "error": "pandoc timed out after 5 minutes."}
|
|
126
173
|
except Exception as e:
|
|
127
174
|
return {"success": False, "error": f"pandoc error: {e}"}
|
|
175
|
+
finally:
|
|
176
|
+
if tmp_md:
|
|
177
|
+
Path(tmp_md).unlink(missing_ok=True)
|
|
128
178
|
|
|
129
179
|
if not out_file.exists():
|
|
130
180
|
return {"success": False, "error": f"Output file was not created: {out_file}"}
|
|
@@ -134,6 +184,7 @@ def doc_convert(
|
|
|
134
184
|
"output_path": str(out_file),
|
|
135
185
|
"from_format": src_fmt,
|
|
136
186
|
"to_format": tgt_fmt,
|
|
187
|
+
"extracted_text": src_fmt == "pdf",
|
|
137
188
|
"pdf_engine": _pdf_engine() if tgt_fmt == "pdf" else None,
|
|
138
189
|
"message": f"MEDIA:{out_file}\n\nConverted {src.name} → {tgt_fmt}: {out_file}",
|
|
139
190
|
}
|
|
@@ -144,7 +195,9 @@ def _infer_format(ext: str) -> str:
|
|
|
144
195
|
mapping = {
|
|
145
196
|
"md": "markdown",
|
|
146
197
|
"markdown": "markdown",
|
|
147
|
-
|
|
198
|
+
# pandoc has no "plain" *reader*; markdown is the closest faithful
|
|
199
|
+
# reading of a text file (headings/lists if present, prose otherwise).
|
|
200
|
+
"txt": "markdown",
|
|
148
201
|
"html": "html",
|
|
149
202
|
"htm": "html",
|
|
150
203
|
"docx": "docx",
|
|
@@ -23,19 +23,6 @@ TEXT_SUFFIXES = {
|
|
|
23
23
|
".rst", ".fountain", ".log", ".py", ".js", ".ts", ".css",
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
# Formats anydoc handles (office + text PDF).
|
|
27
|
-
# Source: anydoc README — supported formats table.
|
|
28
|
-
ANYDOC_SUFFIXES = {
|
|
29
|
-
".doc", ".docx", ".docm",
|
|
30
|
-
".ppt", ".pps", ".pot", ".pptx", ".pptm", ".ppsx", ".ppsm",
|
|
31
|
-
".xls", ".xlsx", ".xlsm", ".xlsb",
|
|
32
|
-
".odt", ".ods", ".odp",
|
|
33
|
-
".rtf",
|
|
34
|
-
".epub",
|
|
35
|
-
".pdf",
|
|
36
|
-
".csv",
|
|
37
|
-
}
|
|
38
|
-
|
|
39
26
|
# Image formats that need OCR (LiteParse + Tesseract).
|
|
40
27
|
IMAGE_SUFFIXES = {
|
|
41
28
|
".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".bmp", ".gif",
|
|
@@ -121,36 +108,36 @@ def read(path: str, max_chars: Optional[int] = None, extract_images: bool = True
|
|
|
121
108
|
text = text[:max_chars]
|
|
122
109
|
return text
|
|
123
110
|
|
|
124
|
-
# 3.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
111
|
+
# 3. Everything left — office formats, PDFs, unknown extensions → anydoc.
|
|
112
|
+
# (Text returned above, so the old `or suffix not in TEXT_SUFFIXES`
|
|
113
|
+
# tail was dead.)
|
|
114
|
+
text = ""
|
|
115
|
+
anydoc_error = None
|
|
116
|
+
try:
|
|
117
|
+
text = _run_anydoc(path)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
# A fallback boundary: any primary-engine failure means "try the other
|
|
120
|
+
# engine". anydoc raises NeedsOcrError for a PDF with no text layer, and
|
|
121
|
+
# its hierarchy (NeedsOcrError -> ConvertError -> Exception) is theirs,
|
|
122
|
+
# not ours — enumerating it is what made the OCR lane unreachable for
|
|
123
|
+
# scanned PDFs.
|
|
124
|
+
anydoc_error = f"{type(e).__name__}: {e}"
|
|
125
|
+
|
|
126
|
+
# 4. No text layer (scanned PDF) or anydoc failed → LiteParse OCR
|
|
127
|
+
if not text.strip():
|
|
128
128
|
try:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
data = _run_liteparse(path, extract_images=extract_images)
|
|
142
|
-
text = data.get("text") or ""
|
|
143
|
-
except Exception as liteparse_err:
|
|
144
|
-
if anydoc_error:
|
|
145
|
-
raise RuntimeError(f"anydoc failed: {anydoc_error}; liteparse fallback also failed: {liteparse_err}")
|
|
146
|
-
raise RuntimeError(f"both anydoc and liteparse returned no text. liteparse: {liteparse_err}")
|
|
147
|
-
|
|
148
|
-
if max_chars:
|
|
149
|
-
text = text[:max_chars]
|
|
150
|
-
return text
|
|
151
|
-
|
|
152
|
-
# Fallback: unknown format, try plain text read
|
|
153
|
-
return _read_text(path, max_chars)
|
|
129
|
+
data = _run_liteparse(path, extract_images=extract_images)
|
|
130
|
+
text = data.get("text") or ""
|
|
131
|
+
except Exception as liteparse_err:
|
|
132
|
+
if anydoc_error:
|
|
133
|
+
raise RuntimeError(
|
|
134
|
+
f"anydoc failed: {anydoc_error}; liteparse fallback also failed: {liteparse_err}"
|
|
135
|
+
)
|
|
136
|
+
raise RuntimeError(f"both anydoc and liteparse returned no text. liteparse: {liteparse_err}")
|
|
137
|
+
|
|
138
|
+
if max_chars:
|
|
139
|
+
text = text[:max_chars]
|
|
140
|
+
return text
|
|
154
141
|
|
|
155
142
|
|
|
156
143
|
def read_meta(path: str) -> Dict[str, Any]:
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""The MCP tool surface: one table, one dispatcher, both transports.
|
|
2
|
+
|
|
3
|
+
The stdio server and the self-hosted HTTP server serve the same five tools and
|
|
4
|
+
used to carry byte-identical copies of this file's contents — 220 lines twice,
|
|
5
|
+
one string apart. Anything that changes a schema or a result shape changes it
|
|
6
|
+
here, once, and both transports move together.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
# One protocol version for both transports. The client's requested version is
|
|
12
|
+
# echoed back when it is one we speak, which is what the spec asks for — the
|
|
13
|
+
# stdio server used to advertise 2024-11-05 while HTTP advertised 2025-06-18
|
|
14
|
+
# for the same tool surface.
|
|
15
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
16
|
+
SUPPORTED_PROTOCOLS = ("2025-06-18", "2025-03-26", "2024-11-05")
|
|
17
|
+
|
|
18
|
+
TOOLS = [
|
|
19
|
+
{
|
|
20
|
+
"name": "doc_read",
|
|
21
|
+
"description": (
|
|
22
|
+
"Read and extract text from documents. Uses anydoc for office formats "
|
|
23
|
+
"(DOCX, XLSX, PPTX, ODT, RTF, EPUB, PDF → Markdown) and LiteParse for OCR "
|
|
24
|
+
"(scanned PDFs, images). Plain text files read directly."
|
|
25
|
+
),
|
|
26
|
+
"inputSchema": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"properties": {
|
|
29
|
+
"path": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"description": "Absolute path to the document.",
|
|
32
|
+
},
|
|
33
|
+
"max_chars": {
|
|
34
|
+
"type": "integer",
|
|
35
|
+
"description": "Maximum characters to return.",
|
|
36
|
+
"default": 1000000,
|
|
37
|
+
},
|
|
38
|
+
"extract_images": {
|
|
39
|
+
"type": "boolean",
|
|
40
|
+
"description": "Extract text from images via OCR (LiteParse + Tesseract).",
|
|
41
|
+
"default": True,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
"required": ["path"],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "doc_read_meta",
|
|
49
|
+
"description": "Read document metadata (PDF info, image EXIF, etc.) via LiteParse.",
|
|
50
|
+
"inputSchema": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"properties": {
|
|
53
|
+
"path": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"description": "Absolute path to the document.",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
"required": ["path"],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"name": "doc_convert",
|
|
63
|
+
"description": (
|
|
64
|
+
"Convert documents between formats (PDF, DOCX, HTML, ODT, EPUB, etc.) "
|
|
65
|
+
"using pandoc + LaTeX. Requires system pandoc and texlive-xetex for PDF output."
|
|
66
|
+
),
|
|
67
|
+
"inputSchema": {
|
|
68
|
+
"type": "object",
|
|
69
|
+
"properties": {
|
|
70
|
+
"input_path": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"description": "Absolute path to the source document.",
|
|
73
|
+
},
|
|
74
|
+
"to_format": {
|
|
75
|
+
"type": "string",
|
|
76
|
+
"description": "Target format: pdf, docx, html, odt, epub, etc.",
|
|
77
|
+
},
|
|
78
|
+
"from_format": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
"description": "Optional source format hint (e.g. markdown, html). Pandoc infers from extension if omitted.",
|
|
81
|
+
},
|
|
82
|
+
"output_path": {
|
|
83
|
+
"type": "string",
|
|
84
|
+
"description": "Optional absolute output path. Defaults to the documents directory (DOCS_OUTPUT_DIR or ~/Documents).",
|
|
85
|
+
},
|
|
86
|
+
"extra_args": {
|
|
87
|
+
"type": "array",
|
|
88
|
+
"items": {"type": "string"},
|
|
89
|
+
"description": "Optional extra pandoc CLI flags.",
|
|
90
|
+
},
|
|
91
|
+
"category": {
|
|
92
|
+
"type": "string",
|
|
93
|
+
"description": "Optional subfolder under documents/ (e.g. 'reports').",
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
"required": ["input_path", "to_format"],
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"name": "doc_create_document",
|
|
101
|
+
"description": (
|
|
102
|
+
"Create .docx, .xlsx, .pptx, .pdf, .odt, .ods, or .odp documents. "
|
|
103
|
+
"For PDFs: pass structured JSON for document-style output, or pass "
|
|
104
|
+
'{\"html\": \"<html>...</html>\"} with full CSS (Grid, Flexbox, gradients, '
|
|
105
|
+
"themes) for visual/print-ready PDFs via weasyprint. "
|
|
106
|
+
"Supports page_size, header, footer, page_numbers options."
|
|
107
|
+
),
|
|
108
|
+
"inputSchema": {
|
|
109
|
+
"type": "object",
|
|
110
|
+
"properties": {
|
|
111
|
+
"format": {
|
|
112
|
+
"type": "string",
|
|
113
|
+
"enum": ["docx", "xlsx", "pptx", "pdf", "odt", "ods", "odp"],
|
|
114
|
+
"description": "Output document format.",
|
|
115
|
+
},
|
|
116
|
+
"content": {
|
|
117
|
+
"anyOf": [
|
|
118
|
+
{"type": "string", "description": "Plain text or HTML content (auto-detected and normalized)."},
|
|
119
|
+
{"type": "object", "description": "Structured content dict. Format-specific schema."},
|
|
120
|
+
],
|
|
121
|
+
"description": "Document content. Accepts plain text, HTML, or structured dict.",
|
|
122
|
+
},
|
|
123
|
+
"output_path": {
|
|
124
|
+
"type": "string",
|
|
125
|
+
"description": "Absolute path for the output file. Defaults to the documents directory.",
|
|
126
|
+
},
|
|
127
|
+
"template": {
|
|
128
|
+
"type": "string",
|
|
129
|
+
"description": "Optional template path — honored by docx (.docx/.dotx) and pptx (.pptx/.potx) creation: styles, theme, headers/footers and slide layouts load from the file. Ignored by every other format.",
|
|
130
|
+
},
|
|
131
|
+
"options": {
|
|
132
|
+
"type": "object",
|
|
133
|
+
"description": "Optional format-specific options (title, page_size, auto_size, etc.)",
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
"required": ["format", "content"],
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"name": "doc_edit_pdf",
|
|
141
|
+
"description": "Edit a PDF page using search-and-replace via PyMuPDF. Provide a natural language instruction like 'change <search> to <replace>'.",
|
|
142
|
+
"inputSchema": {
|
|
143
|
+
"type": "object",
|
|
144
|
+
"properties": {
|
|
145
|
+
"path": {
|
|
146
|
+
"type": "string",
|
|
147
|
+
"description": "Absolute path to the PDF file.",
|
|
148
|
+
},
|
|
149
|
+
"page": {
|
|
150
|
+
"type": "integer",
|
|
151
|
+
"description": "Page number to edit (1-indexed).",
|
|
152
|
+
"default": 1,
|
|
153
|
+
},
|
|
154
|
+
"instruction": {
|
|
155
|
+
"type": "string",
|
|
156
|
+
"description": "Natural language instruction for the edit (e.g. 'change \"foo\" to \"bar\"').",
|
|
157
|
+
},
|
|
158
|
+
"output_path": {
|
|
159
|
+
"type": "string",
|
|
160
|
+
"description": "Optional output path. Defaults to <original>_edited.pdf",
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
"required": ["path", "instruction"],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ── Dispatch ────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
def handle_tool(name: str, args: dict) -> str:
|
|
172
|
+
"""Dispatch a tool call to the appropriate handler and return JSON result."""
|
|
173
|
+
try:
|
|
174
|
+
if name == "doc_read":
|
|
175
|
+
from tabella.read import read
|
|
176
|
+
text = read(
|
|
177
|
+
args["path"],
|
|
178
|
+
max_chars=args.get("max_chars", 1_000_000),
|
|
179
|
+
extract_images=args.get("extract_images", True),
|
|
180
|
+
)
|
|
181
|
+
return json.dumps({
|
|
182
|
+
"success": True,
|
|
183
|
+
"path": args["path"],
|
|
184
|
+
"text": text,
|
|
185
|
+
"length": len(text),
|
|
186
|
+
"message": f"Read {len(text)} chars from {args['path']}",
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
elif name == "doc_read_meta":
|
|
190
|
+
from tabella.read import read_meta
|
|
191
|
+
meta = read_meta(args["path"])
|
|
192
|
+
return json.dumps({"success": True, "path": args["path"], "metadata": meta})
|
|
193
|
+
|
|
194
|
+
elif name == "doc_convert":
|
|
195
|
+
from tabella.convert import doc_convert
|
|
196
|
+
result = doc_convert(
|
|
197
|
+
input_path=args["input_path"],
|
|
198
|
+
to_format=args["to_format"],
|
|
199
|
+
from_format=args.get("from_format"),
|
|
200
|
+
output_path=args.get("output_path"),
|
|
201
|
+
extra_args=args.get("extra_args"),
|
|
202
|
+
category=args.get("category"),
|
|
203
|
+
)
|
|
204
|
+
return json.dumps(result)
|
|
205
|
+
|
|
206
|
+
elif name == "doc_create_document":
|
|
207
|
+
from tabella.create import create_document
|
|
208
|
+
output_path = args.get("output_path")
|
|
209
|
+
if not output_path:
|
|
210
|
+
from tabella import get_output_dir
|
|
211
|
+
import time
|
|
212
|
+
ts = int(time.time())
|
|
213
|
+
fmt = args["format"]
|
|
214
|
+
output_path = str(get_output_dir() / f"doc_{ts}.{fmt}")
|
|
215
|
+
result = create_document(
|
|
216
|
+
format=args["format"],
|
|
217
|
+
content=args["content"],
|
|
218
|
+
output_path=output_path,
|
|
219
|
+
template=args.get("template"),
|
|
220
|
+
options=args.get("options"),
|
|
221
|
+
)
|
|
222
|
+
return json.dumps(result)
|
|
223
|
+
|
|
224
|
+
elif name == "doc_edit_pdf":
|
|
225
|
+
from tabella.create import _edit_pdf_text
|
|
226
|
+
result = _edit_pdf_text(
|
|
227
|
+
path=args["path"],
|
|
228
|
+
page=args.get("page", 1),
|
|
229
|
+
instruction=args["instruction"],
|
|
230
|
+
output_path=args.get("output_path"),
|
|
231
|
+
)
|
|
232
|
+
return json.dumps(result)
|
|
233
|
+
|
|
234
|
+
else:
|
|
235
|
+
return json.dumps({"success": False, "error": f"Unknown tool: {name}"})
|
|
236
|
+
|
|
237
|
+
except Exception as e:
|
|
238
|
+
return json.dumps({"success": False, "error": f"{type(e).__name__}: {e}"})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tabella-docs
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.5
|
|
4
4
|
Summary: Portable document lifecycle suite — create (.docx/.xlsx/.pptx/.pdf/.odt/.ods/.odp), convert (pandoc), and read (anydoc + LiteParse OCR) documents. Agent Plugins v1 compatible.
|
|
5
5
|
Author-email: D Yiapanis <d@yiapanis.co>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -142,9 +142,10 @@ sections, sheets, slides, tables, images, and page breaks.
|
|
|
142
142
|
### Convert
|
|
143
143
|
|
|
144
144
|
Format conversion via pandoc + LaTeX (page size, margins, headers/footers
|
|
145
|
-
configurable). **Pandoc 3.1
|
|
146
|
-
`doc_convert` passes your `from`/`to` through, so all of them work. The
|
|
147
|
-
|
|
145
|
+
configurable). **Pandoc 3.1.11 reports 44 input and 65 output formats** —
|
|
146
|
+
`doc_convert` passes your `from`/`to` through, so all of them work. (The counts
|
|
147
|
+
move between pandoc minors; run `pandoc --list-input-formats` to check yours.)
|
|
148
|
+
The everyday set:
|
|
148
149
|
|
|
149
150
|
| Format | In | Out | Notes |
|
|
150
151
|
|---|---|---|---|
|
|
@@ -152,13 +153,13 @@ everyday set:
|
|
|
152
153
|
| HTML | ✅ | ✅ | html4/html5, chunkedhtml |
|
|
153
154
|
| DOCX (Word) | ✅ | ✅ | |
|
|
154
155
|
| ODT | ✅ | ✅ | + `opendocument` |
|
|
155
|
-
| PDF | ✅ | ✅ | in: text
|
|
156
|
+
| PDF | ✅ | ✅ | in: pandoc cannot read PDF, so the text layer is extracted first (OCR when the page is a scan) and the result is converted; out: needs `pdflatex`/`xelatex` (texlive) — engine auto-selected |
|
|
156
157
|
| LaTeX (`.tex`) | ✅ | ✅ | incl. beamer slides out |
|
|
157
158
|
| RST | ✅ | ✅ | |
|
|
158
159
|
| EPUB | ✅ | ✅ | out: epub2/epub3 |
|
|
159
160
|
| CSV / TSV | ✅ | ✅ | out via markdown/AST route |
|
|
160
161
|
| RTF | ✅ | ✅ | |
|
|
161
|
-
| Plain text (`.txt`) | ✅ | ✅ | |
|
|
162
|
+
| Plain text (`.txt`) | ✅ | ✅ | read as markdown — pandoc has no `plain` *reader*, so headings and lists in a `.txt` survive |
|
|
162
163
|
| Org-mode, MediaWiki, Jira wiki, Textile, DocBook, FB2, OPML, IPynb, JATS | ✅ | ✅ | writers for most |
|
|
163
164
|
| Typst / man / ms / texinfo / AsciiDoc | ✅ | ✅ | typst in+out |
|
|
164
165
|
| Presentations | — | ✅ | beamer, revealjs, slidy, s5, dzslides (from any reader, Markdown especially) |
|
|
@@ -242,7 +243,7 @@ brew install cairo pango gdk-pixbuf
|
|
|
242
243
|
|
|
243
244
|
### Windows
|
|
244
245
|
|
|
245
|
-
tabella runs natively on Windows 10/11 (64-bit, Python 3.
|
|
246
|
+
tabella runs natively on Windows 10/11 (64-bit, Python 3.11+); all five MCP
|
|
246
247
|
tools are exercised on `windows-latest` in CI. Native dependencies, installed
|
|
247
248
|
per feature:
|
|
248
249
|
|
|
@@ -291,7 +292,7 @@ required. The bootstrap runs once; subsequent startups use the existing venv.
|
|
|
291
292
|
### Create a PDF from HTML/CSS
|
|
292
293
|
|
|
293
294
|
```python
|
|
294
|
-
from
|
|
295
|
+
from tabella.create import create_document
|
|
295
296
|
|
|
296
297
|
result = create_document(
|
|
297
298
|
format="pdf",
|
|
@@ -304,7 +305,7 @@ result = create_document(
|
|
|
304
305
|
### Read a DOCX file
|
|
305
306
|
|
|
306
307
|
```python
|
|
307
|
-
from
|
|
308
|
+
from tabella.read import read
|
|
308
309
|
|
|
309
310
|
text = read("/path/to/document.docx")
|
|
310
311
|
# Returns GitHub-Flavored Markdown
|
|
@@ -313,7 +314,7 @@ text = read("/path/to/document.docx")
|
|
|
313
314
|
### Convert Markdown to PDF
|
|
314
315
|
|
|
315
316
|
```python
|
|
316
|
-
from
|
|
317
|
+
from tabella.convert import doc_convert
|
|
317
318
|
|
|
318
319
|
result = doc_convert(
|
|
319
320
|
input_path="/path/to/input.md",
|
|
@@ -6,6 +6,7 @@ tabella/bootstrap.py
|
|
|
6
6
|
tabella/convert.py
|
|
7
7
|
tabella/create.py
|
|
8
8
|
tabella/read.py
|
|
9
|
+
tabella/toolspec.py
|
|
9
10
|
tabella/utils.py
|
|
10
11
|
tabella_docs.egg-info/PKG-INFO
|
|
11
12
|
tabella_docs.egg-info/SOURCES.txt
|
|
@@ -13,8 +14,11 @@ tabella_docs.egg-info/dependency_links.txt
|
|
|
13
14
|
tabella_docs.egg-info/requires.txt
|
|
14
15
|
tabella_docs.egg-info/top_level.txt
|
|
15
16
|
tests/test_bootstrap_paths.py
|
|
17
|
+
tests/test_convert_pdf_input.py
|
|
16
18
|
tests/test_ensure_venv_win.py
|
|
19
|
+
tests/test_mcp_handshake.py
|
|
17
20
|
tests/test_odt_table.py
|
|
18
21
|
tests/test_read_ocr_fallback.py
|
|
19
22
|
tests/test_security_gates.py
|
|
20
|
-
tests/test_text_section_alias.py
|
|
23
|
+
tests/test_text_section_alias.py
|
|
24
|
+
tests/test_version_sync.py
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""doc_convert must read PDFs, not hand them to a pandoc that cannot.
|
|
2
|
+
|
|
3
|
+
pandoc converts *to* PDF only. A PDF input used to fail with "Unknown input
|
|
4
|
+
format pdf"; the route now extracts text through the read lane (text layer, or
|
|
5
|
+
OCR for a scan) and converts the extracted markdown, so .pdf joins the input
|
|
6
|
+
column honestly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
pytest.importorskip("PIL")
|
|
14
|
+
pytest.importorskip("docx")
|
|
15
|
+
|
|
16
|
+
from pathlib import Path # noqa: E402
|
|
17
|
+
|
|
18
|
+
from tabella.convert import doc_convert # noqa: E402
|
|
19
|
+
|
|
20
|
+
needs_pandoc = pytest.mark.skipif(
|
|
21
|
+
shutil.which("pandoc") is None, reason="pandoc not installed"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _assert_docx_text(path: Path, needle: str) -> None:
|
|
26
|
+
from docx import Document
|
|
27
|
+
|
|
28
|
+
text = "\n".join(p.text for p in Document(str(path)).paragraphs)
|
|
29
|
+
assert needle.lower() in text.lower(), f"{needle!r} missing from {path}: {text[:200]!r}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@needs_pandoc
|
|
33
|
+
def test_image_only_pdf_converts_to_docx(tmp_path):
|
|
34
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
35
|
+
|
|
36
|
+
img = Image.new("RGB", (1200, 400), "white")
|
|
37
|
+
draw = ImageDraw.Draw(img)
|
|
38
|
+
try:
|
|
39
|
+
font = ImageFont.truetype(
|
|
40
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 64
|
|
41
|
+
)
|
|
42
|
+
except OSError:
|
|
43
|
+
font = ImageFont.load_default(size=64)
|
|
44
|
+
draw.text((60, 120), "SCANNED HELLO 42", fill="black", font=font)
|
|
45
|
+
pdf = tmp_path / "scan.pdf"
|
|
46
|
+
img.save(pdf, "PDF", resolution=150.0)
|
|
47
|
+
|
|
48
|
+
out = doc_convert(str(pdf), to_format="docx", output_path=str(tmp_path / "scan.docx"))
|
|
49
|
+
|
|
50
|
+
assert out["success"], out
|
|
51
|
+
assert out["from_format"] == "pdf"
|
|
52
|
+
assert out["extracted_text"] is True
|
|
53
|
+
_assert_docx_text(Path(out["output_path"]), "SCANNED HELLO 42")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@needs_pandoc
|
|
57
|
+
def test_plain_text_is_readable_as_markdown(tmp_path):
|
|
58
|
+
# pandoc has no "plain" *reader*; .txt must not be handed to it as one.
|
|
59
|
+
src = tmp_path / "notes.txt"
|
|
60
|
+
src.write_text("# Heading\n\nSome prose.", encoding="utf-8")
|
|
61
|
+
|
|
62
|
+
out = doc_convert(str(src), to_format="docx", output_path=str(tmp_path / "notes.docx"))
|
|
63
|
+
|
|
64
|
+
assert out["success"], out
|
|
65
|
+
assert out["from_format"] == "markdown"
|
|
66
|
+
_assert_docx_text(Path(out["output_path"]), "Some prose")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_hostile_extra_arg_is_rejected_before_the_dependency_check(monkeypatch, tmp_path):
|
|
70
|
+
"""A bad flag must raise as invalid, not come back as "pandoc is missing".
|
|
71
|
+
|
|
72
|
+
The allowlist used to run after _check_deps(), so on a pandoc-less host an
|
|
73
|
+
untrusted --lua-filter looked like a harmless dependency error.
|
|
74
|
+
"""
|
|
75
|
+
src = tmp_path / "x.md"
|
|
76
|
+
src.write_text("# hi", encoding="utf-8")
|
|
77
|
+
monkeypatch.setattr(shutil, "which", lambda name: None)
|
|
78
|
+
|
|
79
|
+
with pytest.raises(ValueError, match="unsupported pandoc argument"):
|
|
80
|
+
doc_convert(str(src), to_format="docx", extra_args=["--lua-filter=evil.lua"])
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""A stdio client must see the real version and its own protocol back.
|
|
2
|
+
|
|
3
|
+
Guards the two drift bugs that shipped: the handshake hardcoded 2024-11-05 in
|
|
4
|
+
one transport while the other said 2025-06-18, and both hardcoded a version
|
|
5
|
+
number that had to be bumped by hand in five places.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
import tabella
|
|
17
|
+
|
|
18
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
19
|
+
SERVER = ROOT / "mcp_server.py"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.skipif(not SERVER.exists(), reason="server not present")
|
|
23
|
+
def test_initialize_reports_version_and_echoes_protocol():
|
|
24
|
+
env = dict(os.environ, TABELL_REEXEC="1") # already running in a venv
|
|
25
|
+
req = {
|
|
26
|
+
"jsonrpc": "2.0",
|
|
27
|
+
"id": 1,
|
|
28
|
+
"method": "initialize",
|
|
29
|
+
"params": {"protocolVersion": "2025-03-26", "capabilities": {}},
|
|
30
|
+
}
|
|
31
|
+
proc = subprocess.run(
|
|
32
|
+
[sys.executable, str(SERVER)],
|
|
33
|
+
input=json.dumps(req) + "\n",
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
timeout=60,
|
|
37
|
+
env=env,
|
|
38
|
+
)
|
|
39
|
+
line = proc.stdout.strip().splitlines()[0]
|
|
40
|
+
info = json.loads(line)["result"]
|
|
41
|
+
assert info["serverInfo"]["version"] == tabella.__version__
|
|
42
|
+
assert info["protocolVersion"] == "2025-03-26", "the client's version must be echoed"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""One version, or a released package that lies about itself.
|
|
2
|
+
|
|
3
|
+
pyproject.toml is the source; tabella.__version__ reads it back (via installed
|
|
4
|
+
metadata when available) and both transports report that value. plugin.json
|
|
5
|
+
must carry the same literal because a manifest cannot compute it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import tabella
|
|
13
|
+
|
|
14
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _pyproject_version() -> str:
|
|
18
|
+
text = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
|
19
|
+
match = re.search(r'^version = "([^"]+)"', text, re.MULTILINE)
|
|
20
|
+
assert match, "pyproject.toml has no version line"
|
|
21
|
+
return match.group(1)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_version_is_read_from_pyproject():
|
|
25
|
+
assert tabella.__version__ == _pyproject_version()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_plugin_manifest_agrees_with_the_package():
|
|
29
|
+
manifest = json.loads((ROOT / "plugin.json").read_text(encoding="utf-8"))
|
|
30
|
+
assert manifest["version"] == tabella.__version__
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_documented_imports_resolve():
|
|
34
|
+
"""README/SPECS show `from tabella.x import y` — those must exist."""
|
|
35
|
+
from tabella.convert import doc_convert # noqa: F401
|
|
36
|
+
from tabella.create import create_document # noqa: F401
|
|
37
|
+
from tabella.read import read, read_meta # noqa: F401
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_both_transports_import_the_same_surface():
|
|
41
|
+
"""The stdio and HTTP servers used to carry two copies of the tool table."""
|
|
42
|
+
for path in (ROOT / "mcp_server.py", ROOT / "scripts" / "mcp_http_server.py"):
|
|
43
|
+
source = path.read_text(encoding="utf-8")
|
|
44
|
+
assert "from tabella.toolspec import" in source, f"{path} does not use the shared surface"
|
|
45
|
+
assert "TOOLS = [" not in source, f"{path} still defines its own tool table"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|