tabella-docs 2.0.3__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.
Files changed (26) hide show
  1. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/PKG-INFO +11 -10
  2. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/README.md +10 -9
  3. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/pyproject.toml +1 -1
  4. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/tabella/__init__.py +30 -1
  5. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/tabella/convert.py +70 -17
  6. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/tabella/read.py +31 -38
  7. tabella_docs-2.0.5/tabella/toolspec.py +238 -0
  8. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/tabella_docs.egg-info/PKG-INFO +11 -10
  9. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/tabella_docs.egg-info/SOURCES.txt +6 -1
  10. tabella_docs-2.0.5/tests/test_convert_pdf_input.py +80 -0
  11. tabella_docs-2.0.5/tests/test_mcp_handshake.py +42 -0
  12. tabella_docs-2.0.5/tests/test_read_ocr_fallback.py +49 -0
  13. tabella_docs-2.0.5/tests/test_version_sync.py +45 -0
  14. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/LICENSE +0 -0
  15. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/setup.cfg +0 -0
  16. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/tabella/bootstrap.py +0 -0
  17. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/tabella/create.py +0 -0
  18. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/tabella/utils.py +0 -0
  19. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/tabella_docs.egg-info/dependency_links.txt +0 -0
  20. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/tabella_docs.egg-info/requires.txt +0 -0
  21. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/tabella_docs.egg-info/top_level.txt +0 -0
  22. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/tests/test_bootstrap_paths.py +0 -0
  23. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/tests/test_ensure_venv_win.py +0 -0
  24. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/tests/test_odt_table.py +0 -0
  25. {tabella_docs-2.0.3 → tabella_docs-2.0.5}/tests/test_security_gates.py +0 -0
  26. {tabella_docs-2.0.3 → 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
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 supports 44 input and 54 output formats** —
146
- `doc_convert` passes your `from`/`to` through, so all of them work. The
147
- everyday set:
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-based PDFs (pandoc's pdf reader); out: needs `pdflatex`/`xelatex` (texlive) — engine auto-selected |
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.10+); all five MCP
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 agent_docs.create import create_document
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 agent_docs.read import read
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 agent_docs.convert import doc_convert
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 supports 44 input and 54 output formats** —
97
- `doc_convert` passes your `from`/`to` through, so all of them work. The
98
- everyday set:
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-based PDFs (pandoc's pdf reader); out: needs `pdflatex`/`xelatex` (texlive) — engine auto-selected |
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.10+); all five MCP
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 agent_docs.create import create_document
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 agent_docs.read import read
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 agent_docs.convert import doc_convert
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.3"
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
- __version__ = "2.0.3"
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", str(src), "-o", str(out_file), "-f", src_fmt, "-t", tgt_fmt]
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
- if extra_args:
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
- "txt": "plain",
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",
@@ -104,7 +91,8 @@ def read(path: str, max_chars: Optional[int] = None, extract_images: bool = True
104
91
  1. Plain text → direct file read (fast path)
105
92
  2. Office formats + text PDFs → anydoc (Markdown output)
106
93
  3. Images → LiteParse (OCR)
107
- 4. PDFs that anydoc returns empty → LiteParse (OCR fallback for scanned PDFs)
94
+ 4. PDFs with no text layer — anydoc returns empty or raises NeedsOcrError →
95
+ LiteParse (OCR fallback for scanned PDFs)
108
96
  """
109
97
  suffix = Path(path).suffix.lower()
110
98
 
@@ -120,31 +108,36 @@ def read(path: str, max_chars: Optional[int] = None, extract_images: bool = True
120
108
  text = text[:max_chars]
121
109
  return text
122
110
 
123
- # 3. Office formats + PDFs → anydoc first
124
- if suffix in ANYDOC_SUFFIXES or suffix not in TEXT_SUFFIXES:
125
- text = ""
126
- anydoc_error = None
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():
127
128
  try:
128
- text = _run_anydoc(path)
129
- except (RuntimeError, ImportError) as e:
130
- anydoc_error = str(e)
131
-
132
- # 4. If anydoc returned empty text (scanned PDF) or failed, try LiteParse OCR
133
- if not text.strip():
134
- try:
135
- data = _run_liteparse(path, extract_images=extract_images)
136
- text = data.get("text") or ""
137
- except (RuntimeError, ImportError) as liteparse_err:
138
- if anydoc_error:
139
- raise RuntimeError(f"anydoc failed: {anydoc_error}; liteparse fallback also failed: {liteparse_err}")
140
- raise RuntimeError(f"both anydoc and liteparse returned no text. liteparse: {liteparse_err}")
141
-
142
- if max_chars:
143
- text = text[:max_chars]
144
- return text
145
-
146
- # Fallback: unknown format, try plain text read
147
- 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
148
141
 
149
142
 
150
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
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 supports 44 input and 54 output formats** —
146
- `doc_convert` passes your `from`/`to` through, so all of them work. The
147
- everyday set:
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-based PDFs (pandoc's pdf reader); out: needs `pdflatex`/`xelatex` (texlive) — engine auto-selected |
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.10+); all five MCP
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 agent_docs.create import create_document
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 agent_docs.read import read
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 agent_docs.convert import doc_convert
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,7 +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
21
+ tests/test_read_ocr_fallback.py
18
22
  tests/test_security_gates.py
19
- 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,49 @@
1
+ """doc_read on an image-only PDF must reach the OCR lane, not raise.
2
+
3
+ anydoc signals a missing text layer by *raising* NeedsOcrError (MRO:
4
+ NeedsOcrError -> ConvertError -> Exception, so not a RuntimeError), where the
5
+ fallback was gated on empty text and caught only RuntimeError/ImportError.
6
+ Net effect: the fallback was unreachable for exactly the input it exists for.
7
+
8
+ Runs the real pipeline — PIL makes the page, Tesseract reads it — because a
9
+ mocked empty return is what let this ship.
10
+ """
11
+
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+ pytest.importorskip("PIL")
17
+ pytest.importorskip("anydoc")
18
+ pytest.importorskip("liteparse")
19
+
20
+ from PIL import Image, ImageDraw, ImageFont # noqa: E402
21
+
22
+
23
+ def _page(tmp_path: Path):
24
+ img = Image.new("RGB", (1200, 400), "white")
25
+ draw = ImageDraw.Draw(img)
26
+ try:
27
+ font = ImageFont.truetype(
28
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 64
29
+ )
30
+ except OSError:
31
+ font = ImageFont.load_default(size=64)
32
+ draw.text((60, 120), "SCANNED HELLO 42", fill="black", font=font)
33
+ return img
34
+
35
+
36
+ def test_image_only_pdf_falls_back_to_ocr(tmp_path):
37
+ png, pdf = tmp_path / "scan.png", tmp_path / "scan.pdf"
38
+ img = _page(tmp_path)
39
+ img.save(png)
40
+ img.save(pdf, "PDF", resolution=150.0)
41
+
42
+ from tabella.read import read
43
+
44
+ from_png = " ".join(read(str(png)).split())
45
+ from_pdf = " ".join(read(str(pdf)).split())
46
+
47
+ assert "HELLO" in from_png.upper(), f"OCR lane itself is broken: {from_png!r}"
48
+ assert from_pdf, "image-only PDF returned no text"
49
+ assert from_pdf == from_png, f"PDF and PNG disagree: {from_pdf!r} vs {from_png!r}"
@@ -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