tabella-docs 2.0.1__tar.gz → 2.0.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tabella-docs
3
- Version: 2.0.1
3
+ Version: 2.0.3
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
@@ -23,9 +23,9 @@ Provides-Extra: create
23
23
  Requires-Dist: python-docx<2.0,>=1.0; extra == "create"
24
24
  Requires-Dist: openpyxl<4.0,>=3.1; extra == "create"
25
25
  Requires-Dist: python-pptx<2.0,>=0.6; extra == "create"
26
- Requires-Dist: weasyprint<70.0,>=60.0; extra == "create"
26
+ Requires-Dist: weasyprint<71.0,>=70.0; extra == "create"
27
27
  Requires-Dist: odfpy<2.0,>=1.4; extra == "create"
28
- Requires-Dist: Pillow<13.0,>=10.0; extra == "create"
28
+ Requires-Dist: Pillow<14.0,>=10.0; extra == "create"
29
29
  Requires-Dist: lxml<7.0,>=5.0; extra == "create"
30
30
  Provides-Extra: read
31
31
  Requires-Dist: firecrawl-anydoc<0.3.0,>=0.1.1; extra == "read"
@@ -36,9 +36,9 @@ Provides-Extra: all
36
36
  Requires-Dist: python-docx<2.0,>=1.0; extra == "all"
37
37
  Requires-Dist: openpyxl<4.0,>=3.1; extra == "all"
38
38
  Requires-Dist: python-pptx<2.0,>=0.6; extra == "all"
39
- Requires-Dist: weasyprint<70.0,>=60.0; extra == "all"
39
+ Requires-Dist: weasyprint<71.0,>=70.0; extra == "all"
40
40
  Requires-Dist: odfpy<2.0,>=1.4; extra == "all"
41
- Requires-Dist: Pillow<13.0,>=10.0; extra == "all"
41
+ Requires-Dist: Pillow<14.0,>=10.0; extra == "all"
42
42
  Requires-Dist: lxml<7.0,>=5.0; extra == "all"
43
43
  Requires-Dist: firecrawl-anydoc<0.3.0,>=0.1.1; extra == "all"
44
44
  Requires-Dist: liteparse<3.0.0,>=2.0.0; extra == "all"
@@ -119,7 +119,7 @@ Or install only the modules you need:
119
119
  ```bash
120
120
  pip install tabella-docs[create] # python-docx, openpyxl, python-pptx, weasyprint, odfpy
121
121
  pip install tabella-docs[read] # firecrawl-anydoc, liteparse, PyMuPDF
122
- pip install tabella-docs[convert] # no python deps (needs system pandoc + texlive-xetex)
122
+ pip install tabella-docs[convert] # no python deps (needs system pandoc; a TeX engine only for pdf output)
123
123
  ```
124
124
 
125
125
  ## Modules
@@ -189,8 +189,8 @@ Set `DOCS_VENV_PYTHON` to point to it:
189
189
 
190
190
  ```bash
191
191
  # Create a dedicated venv
192
- python -m venv ~/.venvs/docs
193
- ~/.venvs/docs/bin/pip install python-docx openpyxl python-pptx weasyprint odfpy Pillow lxml PyMuPDF firecrawl-anydoc liteparse
192
+ python -m venv ~/.venvs/docs/venv
193
+ ~/.venvs/docs/venv/bin/pip install python-docx openpyxl python-pptx weasyprint odfpy Pillow lxml PyMuPDF firecrawl-anydoc liteparse
194
194
  ```
195
195
 
196
196
  Or install the libraries into your main Python environment:
@@ -202,7 +202,7 @@ pip install tabella-docs[create]
202
202
  ### Read module
203
203
 
204
204
  ```bash
205
- ~/.venvs/docs/bin/pip install firecrawl-anydoc liteparse PyMuPDF
205
+ ~/.venvs/docs/venv/bin/pip install firecrawl-anydoc liteparse PyMuPDF
206
206
  ```
207
207
 
208
208
  Or:
@@ -240,6 +240,23 @@ sudo apt install libcairo2 libpango-1.0-0 libgdk-pixbuf-2.0-0
240
240
  brew install cairo pango gdk-pixbuf
241
241
  ```
242
242
 
243
+ ### Windows
244
+
245
+ tabella runs natively on Windows 10/11 (64-bit, Python 3.10+); all five MCP
246
+ tools are exercised on `windows-latest` in CI. Native dependencies, installed
247
+ per feature:
248
+
249
+ - **PDF creation (WeasyPrint)** — the
250
+ [GTK3 runtime installer](https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer)
251
+ (its default install location is on WeasyPrint's search path)
252
+ - **Convert** — [pandoc](https://pandoc.org/installing.html); a TeX engine
253
+ (e.g. MiKTeX) only when the *output* format is PDF
254
+ - **OCR fallback** — [Tesseract for Windows](https://github.com/UB-Mannheim/tesseract/wiki),
255
+ only for scanned PDFs and images
256
+
257
+ The first-run dependency venv bootstraps natively on Windows (stdlib venv
258
+ module — no bash, MSYS2, or WSL required).
259
+
243
260
  ## Installation from source
244
261
 
245
262
  ```bash
@@ -252,7 +269,7 @@ pip install -e ".[all]"
252
269
 
253
270
  | Variable | Purpose | Default |
254
271
  |----------|---------|---------|
255
- | `DOCS_VENV_PYTHON` | Python binary for document libraries | `PLUGIN_DATA/venv/bin/python` or `~/.venvs/docs/bin/python` |
272
+ | `DOCS_VENV_PYTHON` | Python binary for document libraries | `PLUGIN_DATA/venv/bin/python` or `~/.venvs/docs/venv/bin/python` |
256
273
  | `DOCS_OUTPUT_DIR` | Default output directory for generated documents | `~/Documents` |
257
274
  | `PLUGIN_DATA` | Client-managed data directory (set by v1 client) | — |
258
275
  | `PLUGIN_ROOT` | Plugin root directory (set by v1 client) | — |
@@ -260,10 +277,11 @@ pip install -e ".[all]"
260
277
  ### Automatic bootstrap
261
278
 
262
279
  On first startup, the MCP server checks if the docs venv exists. If not,
263
- it runs `scripts/setup.sh` to create a venv in `PLUGIN_DATA` (provided by
264
- the v1 client) or `~/.venvs/docs` as fallback, and installs all dependencies:
280
+ it creates one in `PLUGIN_DATA` (provided by the v1 client) or
281
+ `~/.venvs/docs` as fallback, and installs all dependencies:
265
282
  python-docx, openpyxl, python-pptx, weasyprint, odfpy, PyMuPDF,
266
- firecrawl-anydoc, liteparse.
283
+ firecrawl-anydoc, liteparse. On POSIX this runs `scripts/setup.sh`; on
284
+ Windows the venv is built with the standard library's `venv` module.
267
285
 
268
286
  This means the v1 package is fully self-contained — no manual pip install
269
287
  required. The bootstrap runs once; subsequent startups use the existing venv.
@@ -70,7 +70,7 @@ Or install only the modules you need:
70
70
  ```bash
71
71
  pip install tabella-docs[create] # python-docx, openpyxl, python-pptx, weasyprint, odfpy
72
72
  pip install tabella-docs[read] # firecrawl-anydoc, liteparse, PyMuPDF
73
- pip install tabella-docs[convert] # no python deps (needs system pandoc + texlive-xetex)
73
+ pip install tabella-docs[convert] # no python deps (needs system pandoc; a TeX engine only for pdf output)
74
74
  ```
75
75
 
76
76
  ## Modules
@@ -140,8 +140,8 @@ Set `DOCS_VENV_PYTHON` to point to it:
140
140
 
141
141
  ```bash
142
142
  # Create a dedicated venv
143
- python -m venv ~/.venvs/docs
144
- ~/.venvs/docs/bin/pip install python-docx openpyxl python-pptx weasyprint odfpy Pillow lxml PyMuPDF firecrawl-anydoc liteparse
143
+ python -m venv ~/.venvs/docs/venv
144
+ ~/.venvs/docs/venv/bin/pip install python-docx openpyxl python-pptx weasyprint odfpy Pillow lxml PyMuPDF firecrawl-anydoc liteparse
145
145
  ```
146
146
 
147
147
  Or install the libraries into your main Python environment:
@@ -153,7 +153,7 @@ pip install tabella-docs[create]
153
153
  ### Read module
154
154
 
155
155
  ```bash
156
- ~/.venvs/docs/bin/pip install firecrawl-anydoc liteparse PyMuPDF
156
+ ~/.venvs/docs/venv/bin/pip install firecrawl-anydoc liteparse PyMuPDF
157
157
  ```
158
158
 
159
159
  Or:
@@ -191,6 +191,23 @@ sudo apt install libcairo2 libpango-1.0-0 libgdk-pixbuf-2.0-0
191
191
  brew install cairo pango gdk-pixbuf
192
192
  ```
193
193
 
194
+ ### Windows
195
+
196
+ tabella runs natively on Windows 10/11 (64-bit, Python 3.10+); all five MCP
197
+ tools are exercised on `windows-latest` in CI. Native dependencies, installed
198
+ per feature:
199
+
200
+ - **PDF creation (WeasyPrint)** — the
201
+ [GTK3 runtime installer](https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer)
202
+ (its default install location is on WeasyPrint's search path)
203
+ - **Convert** — [pandoc](https://pandoc.org/installing.html); a TeX engine
204
+ (e.g. MiKTeX) only when the *output* format is PDF
205
+ - **OCR fallback** — [Tesseract for Windows](https://github.com/UB-Mannheim/tesseract/wiki),
206
+ only for scanned PDFs and images
207
+
208
+ The first-run dependency venv bootstraps natively on Windows (stdlib venv
209
+ module — no bash, MSYS2, or WSL required).
210
+
194
211
  ## Installation from source
195
212
 
196
213
  ```bash
@@ -203,7 +220,7 @@ pip install -e ".[all]"
203
220
 
204
221
  | Variable | Purpose | Default |
205
222
  |----------|---------|---------|
206
- | `DOCS_VENV_PYTHON` | Python binary for document libraries | `PLUGIN_DATA/venv/bin/python` or `~/.venvs/docs/bin/python` |
223
+ | `DOCS_VENV_PYTHON` | Python binary for document libraries | `PLUGIN_DATA/venv/bin/python` or `~/.venvs/docs/venv/bin/python` |
207
224
  | `DOCS_OUTPUT_DIR` | Default output directory for generated documents | `~/Documents` |
208
225
  | `PLUGIN_DATA` | Client-managed data directory (set by v1 client) | — |
209
226
  | `PLUGIN_ROOT` | Plugin root directory (set by v1 client) | — |
@@ -211,10 +228,11 @@ pip install -e ".[all]"
211
228
  ### Automatic bootstrap
212
229
 
213
230
  On first startup, the MCP server checks if the docs venv exists. If not,
214
- it runs `scripts/setup.sh` to create a venv in `PLUGIN_DATA` (provided by
215
- the v1 client) or `~/.venvs/docs` as fallback, and installs all dependencies:
231
+ it creates one in `PLUGIN_DATA` (provided by the v1 client) or
232
+ `~/.venvs/docs` as fallback, and installs all dependencies:
216
233
  python-docx, openpyxl, python-pptx, weasyprint, odfpy, PyMuPDF,
217
- firecrawl-anydoc, liteparse.
234
+ firecrawl-anydoc, liteparse. On POSIX this runs `scripts/setup.sh`; on
235
+ Windows the venv is built with the standard library's `venv` module.
218
236
 
219
237
  This means the v1 package is fully self-contained — no manual pip install
220
238
  required. The bootstrap runs once; subsequent startups use the existing venv.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tabella-docs"
7
- version = "2.0.1"
7
+ version = "2.0.3"
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"
@@ -31,9 +31,9 @@ create = [
31
31
  "python-docx>=1.0,<2.0",
32
32
  "openpyxl>=3.1,<4.0",
33
33
  "python-pptx>=0.6,<2.0",
34
- "weasyprint>=60.0,<70.0",
34
+ "weasyprint>=70.0,<71.0",
35
35
  "odfpy>=1.4,<2.0",
36
- "Pillow>=10.0,<13.0",
36
+ "Pillow>=10.0,<14.0",
37
37
  "lxml>=5.0,<7.0",
38
38
  ]
39
39
  read = [
@@ -46,9 +46,9 @@ all = [
46
46
  "python-docx>=1.0,<2.0",
47
47
  "openpyxl>=3.1,<4.0",
48
48
  "python-pptx>=0.6,<2.0",
49
- "weasyprint>=60.0,<70.0",
49
+ "weasyprint>=70.0,<71.0",
50
50
  "odfpy>=1.4,<2.0",
51
- "Pillow>=10.0,<13.0",
51
+ "Pillow>=10.0,<14.0",
52
52
  "lxml>=5.0,<7.0",
53
53
  "firecrawl-anydoc>=0.1.1,<0.3.0",
54
54
  "liteparse>=2.0.0,<3.0.0",
@@ -11,7 +11,9 @@ from __future__ import annotations
11
11
  import os
12
12
  from pathlib import Path
13
13
 
14
- __version__ = "1.0.0"
14
+ from .bootstrap import resolve_venv_python
15
+
16
+ __version__ = "2.0.3"
15
17
 
16
18
  # ── Configuration ─────────────────────────────────────────────
17
19
 
@@ -21,19 +23,11 @@ def get_docs_venv_python() -> str:
21
23
  Resolution order:
22
24
  1. DOCS_VENV_PYTHON env var (explicit override)
23
25
  2. PLUGIN_DATA/venv/bin/python (v1 client-managed data directory)
24
- 3. ~/.venvs/docs/bin/python (fallback)
25
- """
26
- explicit = os.environ.get("DOCS_VENV_PYTHON")
27
- if explicit and Path(explicit).exists():
28
- return explicit
26
+ 3. ~/.venvs/docs/venv/bin/python (fallback)
29
27
 
30
- plugin_data = os.environ.get("PLUGIN_DATA")
31
- if plugin_data:
32
- p = Path(plugin_data) / "venv" / "bin" / "python"
33
- if p.exists():
34
- return str(p)
35
-
36
- return str(Path.home() / ".venvs" / "docs" / "bin" / "python")
28
+ Delegates to tabella.bootstrap so the path can only be wrong in one place.
29
+ """
30
+ return resolve_venv_python()
37
31
 
38
32
 
39
33
  def get_output_dir() -> Path:
@@ -0,0 +1,144 @@
1
+ """Shared docs-venv bootstrap for the stdio and HTTP servers.
2
+
3
+ `docs_root` is the directory that owns the venv; the venv itself always lives
4
+ at `<docs_root>/venv`, which is exactly what scripts/setup.sh builds. Keep
5
+ those two in step: the interpreter returned here is the one setup.sh created,
6
+ and `tests/test_bootstrap_paths.py` asserts that instead of hard-coding a path
7
+ that can drift again.
8
+
9
+ Checks DOCS_VENV_PYTHON first. Otherwise the root is PLUGIN_DATA (provided by
10
+ the v1 client) or ~/.venvs/docs.
11
+
12
+ Platform notes:
13
+ - POSIX runs scripts/setup.sh via bash.
14
+ - Windows has no bash on a stock box — the venv is built with the stdlib
15
+ venv module + the venv's own pip, mirroring setup.sh
16
+ (--require-hashes -r requirements-pinned.txt).
17
+ - The venv layout differs: bin/python vs Scripts/python.exe.
18
+ """
19
+
20
+ import os
21
+ import subprocess
22
+ import sys
23
+ from pathlib import Path
24
+
25
+
26
+ def docs_root() -> Path:
27
+ """Directory that owns the docs venv (the argument setup.sh takes)."""
28
+ plugin_data = os.environ.get("PLUGIN_DATA")
29
+ if plugin_data:
30
+ return Path(plugin_data)
31
+ return Path.home() / ".venvs" / "docs"
32
+
33
+
34
+ def venv_python_path() -> str:
35
+ """Interpreter inside the docs venv — `<docs_root>/venv`, as setup.sh builds it."""
36
+ venv_dir = docs_root() / "venv"
37
+ if os.name == "nt":
38
+ return str(venv_dir / "Scripts" / "python.exe")
39
+ return str(venv_dir / "bin" / "python")
40
+
41
+
42
+ def resolve_venv_python() -> str:
43
+ """Interpreter to use, honouring an explicit DOCS_VENV_PYTHON override."""
44
+ explicit = os.environ.get("DOCS_VENV_PYTHON")
45
+ if explicit and Path(explicit).exists():
46
+ return explicit
47
+ return venv_python_path()
48
+
49
+
50
+ def _venv_ready(venv_python: str) -> bool:
51
+ """Is this a *usable* docs venv, not just a leftover interpreter?
52
+
53
+ Existence alone is not enough: an interrupted first-run install leaves
54
+ bin/python behind with none of the packages, and every later start then
55
+ accepts it and fails at import time. A sentinel marks a completed install;
56
+ a venv without one is probed once (cheap) and adopted only if the imports
57
+ work — existing venvs created before the sentinel existed are not
58
+ re-bootstrapped for nothing.
59
+ """
60
+ if not Path(venv_python).exists():
61
+ return False
62
+ marker = Path(venv_python).parent.parent / ".tabella-deps-ok"
63
+ if marker.exists():
64
+ return True
65
+ # weasyprint is deliberately absent from this probe: importing it needs
66
+ # pango/GTK, which a host may legitimately lack (PDF then fails at call
67
+ # time with a clear error). Everything else is pure Python or bundled.
68
+ try:
69
+ probe = subprocess.run(
70
+ [venv_python, "-c", "import docx, openpyxl, pptx, odf, anydoc, liteparse, pymupdf"],
71
+ capture_output=True, text=True, timeout=120,
72
+ )
73
+ except (OSError, subprocess.SubprocessError):
74
+ return False # hung or unexecutable: treat as not ready, rebuild
75
+ if probe.returncode == 0:
76
+ try:
77
+ marker.write_text("")
78
+ except OSError:
79
+ pass # a read-only venv is still usable; the probe will run again
80
+ return True
81
+ return False
82
+
83
+
84
+ def ensure_venv() -> str:
85
+ """Return the docs-venv interpreter, bootstrapping it on first use."""
86
+ explicit = os.environ.get("DOCS_VENV_PYTHON")
87
+ if explicit and Path(explicit).exists():
88
+ return explicit # the operator named an interpreter: use it as-is
89
+
90
+ venv_python = venv_python_path()
91
+ if _venv_ready(venv_python):
92
+ return venv_python
93
+
94
+ root = docs_root()
95
+ root.mkdir(parents=True, exist_ok=True) # ~/.venvs may not exist on a cold host
96
+ venv_dir = root / "venv"
97
+ marker = venv_dir / ".tabella-deps-ok"
98
+ plugin_root = Path(__file__).parent.parent.resolve()
99
+
100
+ if os.name == "nt":
101
+ # No bash on a stock Windows box — build the venv with the stdlib
102
+ # venv module + the venv's own pip, mirroring scripts/setup.sh.
103
+ pinned = plugin_root / "requirements-pinned.txt"
104
+ sys.stderr.write("tabella: bootstrapping docs venv (first run)...\n")
105
+ result = subprocess.run(
106
+ [sys.executable, "-m", "venv", str(venv_dir)],
107
+ capture_output=True, text=True, timeout=600,
108
+ )
109
+ if result.returncode == 0:
110
+ subprocess.run(
111
+ [venv_python, "-m", "pip", "install", "--quiet", "--upgrade", "pip"],
112
+ capture_output=True, text=True, timeout=600,
113
+ )
114
+ result = subprocess.run(
115
+ [venv_python, "-m", "pip", "install", "--quiet", "--require-hashes",
116
+ "-r", str(pinned)],
117
+ capture_output=True, text=True, timeout=600,
118
+ )
119
+ if result.returncode != 0:
120
+ sys.stderr.write(f"tabella: bootstrap failed: {result.stderr[:500]}\n")
121
+ elif Path(venv_python).exists():
122
+ marker.write_text("")
123
+ sys.stderr.write("tabella: venv ready\n")
124
+ return venv_python
125
+
126
+ # POSIX bootstrap: run the setup script on the docs root — it creates
127
+ # <docs_root>/venv, which is what venv_python points at.
128
+ setup_script = plugin_root / "scripts" / "setup.sh"
129
+ if not setup_script.exists():
130
+ sys.stderr.write(f"tabella: setup script not found at {setup_script}\n")
131
+ return venv_python # let tool calls fail with a helpful error
132
+
133
+ sys.stderr.write("tabella: bootstrapping docs venv (first run)...\n")
134
+ result = subprocess.run(
135
+ ["bash", str(setup_script), str(root)],
136
+ capture_output=True, text=True, timeout=300,
137
+ )
138
+ if result.returncode != 0:
139
+ sys.stderr.write(f"tabella: bootstrap failed: {result.stderr[:500]}\n")
140
+ elif Path(venv_python).exists():
141
+ marker.write_text("")
142
+ sys.stderr.write("tabella: venv ready\n")
143
+
144
+ return venv_python
@@ -5,6 +5,7 @@ Output directory is configurable via DOCS_OUTPUT_DIR (defaults to ~/Documents).
5
5
  Merged from the doc_convert plugin.
6
6
  """
7
7
 
8
+ import re
8
9
  import json
9
10
  import logging
10
11
  import shutil
@@ -17,11 +18,11 @@ from .utils import _get_output_dir, _is_in_output_dir, _resolve_output_dir
17
18
  logger = logging.getLogger(__name__)
18
19
 
19
20
 
20
- def _check_deps() -> Optional[str]:
21
- """Return error string if pandoc or LaTeX is missing, else None."""
21
+ def _check_deps(needs_tex: bool = False) -> Optional[str]:
22
+ """Return error string if pandoc — or, for pdf targets, LaTeX — is missing."""
22
23
  if not shutil.which("pandoc"):
23
24
  return "pandoc is not installed. Install it (e.g. `sudo apt install pandoc`)."
24
- if not shutil.which("pdflatex") and not shutil.which("xelatex"):
25
+ if needs_tex and not shutil.which("pdflatex") and not shutil.which("xelatex"):
25
26
  return "No LaTeX engine found. Install texlive (e.g. `sudo apt install texlive-xetex`)."
26
27
  return None
27
28
 
@@ -51,17 +52,15 @@ def doc_convert(
51
52
  When omitted, pandoc infers from file extension.
52
53
  output_path: Optional absolute path for the output file.
53
54
  When omitted, writes to the user's documents directory.
54
- extra_args: Optional extra pandoc CLI flags (e.g. ["--toc", "-V", "geometry:margin=1in"]).
55
+ extra_args: Optional extra pandoc CLI flags (e.g. ["--toc", "-Vmargin=1in"]).
56
+ NOTE: -V must be a single token ("-Vkey=value") — the
57
+ two-token "-V key=value" form is rejected by the allowlist.
55
58
  category: Optional subfolder under ~/documents/ (e.g. "reports", "invoices").
56
59
 
57
60
  Returns:
58
61
  {"success": bool, "output_path": str, "from_format": str, "to_format": str,
59
62
  "pdf_engine": str | None, "message": str}
60
63
  """
61
- dep_err = _check_deps()
62
- if dep_err:
63
- return {"success": False, "error": dep_err}
64
-
65
64
  src = Path(input_path).expanduser().resolve()
66
65
  if not src.exists():
67
66
  return {"success": False, "error": f"Source file not found: {src}"}
@@ -69,6 +68,13 @@ def doc_convert(
69
68
  # Determine formats
70
69
  src_fmt = from_format or _infer_format(src.suffix.lstrip(".").lower())
71
70
  tgt_fmt = to_format.lower()
71
+ _check_format(src_fmt, "from_format")
72
+ _check_format(tgt_fmt, "to_format")
73
+
74
+ # pandoc is always required; a TeX engine only for pdf output
75
+ dep_err = _check_deps(tgt_fmt == "pdf")
76
+ if dep_err:
77
+ return {"success": False, "error": dep_err}
72
78
 
73
79
  # Determine output path
74
80
  if output_path:
@@ -93,7 +99,20 @@ def doc_convert(
93
99
  engine = _pdf_engine()
94
100
  cmd.extend(["--pdf-engine", engine])
95
101
  if extra_args:
96
- cmd.extend(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
+ )
97
116
 
98
117
  try:
99
118
  result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
@@ -138,6 +157,22 @@ def _infer_format(ext: str) -> str:
138
157
  return mapping.get(ext, ext)
139
158
 
140
159
 
160
+ # Pandoc treats a format name ending in ".lua" as a path to a custom Lua
161
+ # reader/writer and executes it with the full Lua stdlib (os.execute) —
162
+ # same code-exec class as --lua-filter via a different argument. Restrict
163
+ # format names to pandoc's built-in name grammar (catalog finding 2026-10-17).
164
+ _FORMAT_RE = re.compile(r"^[a-z0-9_]+([+-][a-z0-9_]+)*$")
165
+
166
+
167
+ def _check_format(name: str, which: str) -> None:
168
+ """Reject anything that isn't a built-in pandoc format name."""
169
+ if not _FORMAT_RE.match(name):
170
+ raise ValueError(
171
+ f"unsupported {which} {name!r}: must be a built-in pandoc format "
172
+ "(alphanumeric/underscore, optional +ext/-ext modifiers)"
173
+ )
174
+
175
+
141
176
  def _handle_doc_convert(args: dict, **kwargs) -> str:
142
177
  """Handler wrapper for doc_convert tool."""
143
178
  result = doc_convert(**args)