doc-toolkit-mcp 0.1.0__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.
- doc_toolkit_mcp-0.1.0/LICENSE +21 -0
- doc_toolkit_mcp-0.1.0/PKG-INFO +71 -0
- doc_toolkit_mcp-0.1.0/README.md +47 -0
- doc_toolkit_mcp-0.1.0/pyproject.toml +39 -0
- doc_toolkit_mcp-0.1.0/src/doc_toolkit_mcp/__init__.py +3 -0
- doc_toolkit_mcp-0.1.0/src/doc_toolkit_mcp/server.py +346 -0
- doc_toolkit_mcp-0.1.0/tests/test_smoke.py +45 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 B2B增长工坊
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: doc-toolkit-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for local document work: convert to Markdown, OCR scanned files, chunk for retrieval, and split or merge PDFs
|
|
5
|
+
Project-URL: Homepage, https://open.workbuddy.cn
|
|
6
|
+
Author: B2B增长工坊
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: markdown,mcp,ocr,pdf,rag
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: jieba>=0.42
|
|
15
|
+
Requires-Dist: mcp>=1.2.0
|
|
16
|
+
Requires-Dist: openpyxl>=3.1
|
|
17
|
+
Requires-Dist: pymupdf>=1.24
|
|
18
|
+
Requires-Dist: pypdf>=4.0
|
|
19
|
+
Requires-Dist: python-docx>=1.1
|
|
20
|
+
Requires-Dist: python-pptx>=0.6.23
|
|
21
|
+
Provides-Extra: ocr
|
|
22
|
+
Requires-Dist: rapidocr-onnxruntime>=1.3; extra == 'ocr'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# doc-toolkit-mcp
|
|
26
|
+
|
|
27
|
+
MCP server for local document work. **Everything runs on your machine** — no upload, no API key, no account.
|
|
28
|
+
|
|
29
|
+
| Tool | What it does |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `convert_to_markdown` | PDF / DOCX / PPTX / XLSX → Markdown, tables preserved |
|
|
32
|
+
| `ocr_extract` | Images and scanned PDFs → text (optional OCR extra) |
|
|
33
|
+
| `split_chunks` | Split a document into retrieval-sized chunks |
|
|
34
|
+
| `pdf_merge` | Merge several PDFs in order |
|
|
35
|
+
| `pdf_split` | Split by page count, or extract a page range |
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uvx doc-toolkit-mcp
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
With OCR support:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uvx --with rapidocr-onnxruntime doc-toolkit-mcp
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## MCP client config
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"mcpServers": {
|
|
54
|
+
"doc-toolkit": {
|
|
55
|
+
"command": "uvx",
|
|
56
|
+
"args": ["doc-toolkit-mcp"]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Behaviour notes
|
|
63
|
+
|
|
64
|
+
- `convert_to_markdown` reads the text layer only; scanned PDFs come back empty and should go through `ocr_extract`.
|
|
65
|
+
- `ocr_extract` checks each PDF page for a text layer first and only OCRs pages that lack one.
|
|
66
|
+
- `split_chunks` keeps the nearest heading with every chunk so retrieved passages carry context.
|
|
67
|
+
- All paths are absolute paths on the machine running the client.
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# doc-toolkit-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for local document work. **Everything runs on your machine** — no upload, no API key, no account.
|
|
4
|
+
|
|
5
|
+
| Tool | What it does |
|
|
6
|
+
|---|---|
|
|
7
|
+
| `convert_to_markdown` | PDF / DOCX / PPTX / XLSX → Markdown, tables preserved |
|
|
8
|
+
| `ocr_extract` | Images and scanned PDFs → text (optional OCR extra) |
|
|
9
|
+
| `split_chunks` | Split a document into retrieval-sized chunks |
|
|
10
|
+
| `pdf_merge` | Merge several PDFs in order |
|
|
11
|
+
| `pdf_split` | Split by page count, or extract a page range |
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uvx doc-toolkit-mcp
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
With OCR support:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uvx --with rapidocr-onnxruntime doc-toolkit-mcp
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## MCP client config
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"mcpServers": {
|
|
30
|
+
"doc-toolkit": {
|
|
31
|
+
"command": "uvx",
|
|
32
|
+
"args": ["doc-toolkit-mcp"]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Behaviour notes
|
|
39
|
+
|
|
40
|
+
- `convert_to_markdown` reads the text layer only; scanned PDFs come back empty and should go through `ocr_extract`.
|
|
41
|
+
- `ocr_extract` checks each PDF page for a text layer first and only OCRs pages that lack one.
|
|
42
|
+
- `split_chunks` keeps the nearest heading with every chunk so retrieved passages carry context.
|
|
43
|
+
- All paths are absolute paths on the machine running the client.
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
MIT
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "doc-toolkit-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server for local document work: convert to Markdown, OCR scanned files, chunk for retrieval, and split or merge PDFs"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "B2B增长工坊" }]
|
|
13
|
+
keywords = ["mcp", "pdf", "ocr", "markdown", "rag"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"mcp>=1.2.0",
|
|
21
|
+
"pymupdf>=1.24",
|
|
22
|
+
"python-docx>=1.1",
|
|
23
|
+
"python-pptx>=0.6.23",
|
|
24
|
+
"openpyxl>=3.1",
|
|
25
|
+
"pypdf>=4.0",
|
|
26
|
+
"jieba>=0.42",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
ocr = ["rapidocr-onnxruntime>=1.3"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://open.workbuddy.cn"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
doc-toolkit-mcp = "doc_toolkit_mcp.server:main"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/doc_toolkit_mcp"]
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Local document toolkit MCP server.
|
|
2
|
+
|
|
3
|
+
Everything runs on the user's machine — no upload, no API key.
|
|
4
|
+
|
|
5
|
+
Tools:
|
|
6
|
+
- ``convert_to_markdown`` PDF / DOCX / PPTX / XLSX → Markdown
|
|
7
|
+
- ``ocr_extract`` images and scanned PDFs → text (needs rapidocr-onnxruntime)
|
|
8
|
+
- ``split_chunks`` split a document into retrieval-sized chunks
|
|
9
|
+
- ``pdf_merge`` merge several PDFs
|
|
10
|
+
- ``pdf_split`` split a PDF by page count or page range
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
try: # mcp SDK v1
|
|
18
|
+
from mcp.server.fastmcp import FastMCP
|
|
19
|
+
except ImportError: # mcp SDK v2 renamed FastMCP to MCPServer; decorator API unchanged
|
|
20
|
+
from mcp.server.mcpserver import MCPServer as FastMCP
|
|
21
|
+
|
|
22
|
+
mcp = FastMCP("doc-toolkit")
|
|
23
|
+
|
|
24
|
+
TEXT_EXT = {".md", ".txt", ".csv"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── extraction ───────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
def _fmt_table(rows: list[list[str]]) -> str:
|
|
30
|
+
if not rows:
|
|
31
|
+
return ""
|
|
32
|
+
width = max(len(r) for r in rows)
|
|
33
|
+
rows = [r + [""] * (width - len(r)) for r in rows]
|
|
34
|
+
lines = ["| " + " | ".join(rows[0]) + " |", "|" + "|".join(["---"] * width) + "|"]
|
|
35
|
+
lines += ["| " + " | ".join(r) + " |" for r in rows[1:]]
|
|
36
|
+
return "\n".join(lines)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _from_pdf(path: Path) -> str:
|
|
40
|
+
import fitz
|
|
41
|
+
|
|
42
|
+
parts = []
|
|
43
|
+
with fitz.open(path) as doc:
|
|
44
|
+
for i, page in enumerate(doc, 1):
|
|
45
|
+
text = page.get_text("text").strip()
|
|
46
|
+
if text:
|
|
47
|
+
parts.append(f"\n\n<!-- page {i} -->\n\n{text}")
|
|
48
|
+
return "".join(parts)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _from_docx(path: Path) -> str:
|
|
52
|
+
from docx import Document
|
|
53
|
+
|
|
54
|
+
doc = Document(str(path))
|
|
55
|
+
out = []
|
|
56
|
+
for p in doc.paragraphs:
|
|
57
|
+
t = p.text.strip()
|
|
58
|
+
if not t:
|
|
59
|
+
continue
|
|
60
|
+
style = (p.style.name or "").lower()
|
|
61
|
+
if style.startswith("heading"):
|
|
62
|
+
lvl = "".join(ch for ch in style if ch.isdigit()) or "1"
|
|
63
|
+
out.append("#" * min(int(lvl), 6) + " " + t)
|
|
64
|
+
elif style.startswith("title"):
|
|
65
|
+
out.append("# " + t)
|
|
66
|
+
else:
|
|
67
|
+
out.append(t)
|
|
68
|
+
for ti, table in enumerate(doc.tables, 1):
|
|
69
|
+
rows = [[c.text.strip().replace("\n", " ") for c in r.cells] for r in table.rows]
|
|
70
|
+
if rows:
|
|
71
|
+
out.append(f"**表 {ti}**\n\n" + _fmt_table(rows))
|
|
72
|
+
return "\n\n".join(out)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _from_pptx(path: Path) -> str:
|
|
76
|
+
from pptx import Presentation
|
|
77
|
+
|
|
78
|
+
prs = Presentation(str(path))
|
|
79
|
+
out = []
|
|
80
|
+
for i, slide in enumerate(prs.slides, 1):
|
|
81
|
+
out.append(f"\n## Slide {i}\n")
|
|
82
|
+
for shape in slide.shapes:
|
|
83
|
+
if shape.has_text_frame and shape.text_frame.text.strip():
|
|
84
|
+
out.append(shape.text_frame.text.strip())
|
|
85
|
+
if getattr(shape, "has_table", False):
|
|
86
|
+
rows = [[c.text.strip() for c in r.cells] for r in shape.table.rows]
|
|
87
|
+
if rows:
|
|
88
|
+
out.append(_fmt_table(rows))
|
|
89
|
+
return "\n\n".join(out)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _from_xlsx(path: Path) -> str:
|
|
93
|
+
from openpyxl import load_workbook
|
|
94
|
+
|
|
95
|
+
wb = load_workbook(str(path), read_only=True, data_only=True)
|
|
96
|
+
out = []
|
|
97
|
+
for ws in wb.worksheets:
|
|
98
|
+
out.append(f"\n## {ws.title}\n")
|
|
99
|
+
rows = []
|
|
100
|
+
for row in ws.iter_rows(values_only=True):
|
|
101
|
+
if any(v not in (None, "") for v in row):
|
|
102
|
+
rows.append([" " if v is None else str(v).strip() for v in row])
|
|
103
|
+
if rows:
|
|
104
|
+
out.append(_fmt_table(rows))
|
|
105
|
+
wb.close()
|
|
106
|
+
return "\n\n".join(out)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _extract(path: Path) -> str:
|
|
110
|
+
ext = path.suffix.lower()
|
|
111
|
+
if ext == ".pdf":
|
|
112
|
+
return _from_pdf(path)
|
|
113
|
+
if ext == ".docx":
|
|
114
|
+
return _from_docx(path)
|
|
115
|
+
if ext == ".pptx":
|
|
116
|
+
return _from_pptx(path)
|
|
117
|
+
if ext in (".xlsx", ".xlsm"):
|
|
118
|
+
return _from_xlsx(path)
|
|
119
|
+
if ext in TEXT_EXT:
|
|
120
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
121
|
+
raise ValueError(f"unsupported format: {ext}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── chunking ─────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
def _token_len(text: str) -> int:
|
|
127
|
+
try:
|
|
128
|
+
import logging
|
|
129
|
+
|
|
130
|
+
import jieba
|
|
131
|
+
|
|
132
|
+
# jieba 首次加载会把 DEBUG 日志打到 stderr,污染 MCP 输出
|
|
133
|
+
jieba.setLogLevel(logging.WARNING)
|
|
134
|
+
return len([w for w in jieba.lcut(text) if w.strip()])
|
|
135
|
+
except Exception:
|
|
136
|
+
return len(text)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _chunk(md: str, size: int, overlap: int):
|
|
140
|
+
sections, head, buf = [], "", []
|
|
141
|
+
for line in md.split("\n"):
|
|
142
|
+
m = re.match(r"^(#{1,6})\s+(.*)$", line)
|
|
143
|
+
if m:
|
|
144
|
+
if buf and "".join(buf).strip():
|
|
145
|
+
sections.append((head, "\n".join(buf).strip()))
|
|
146
|
+
head, buf = line.strip(), []
|
|
147
|
+
else:
|
|
148
|
+
buf.append(line)
|
|
149
|
+
if buf and "".join(buf).strip():
|
|
150
|
+
sections.append((head, "\n".join(buf).strip()))
|
|
151
|
+
|
|
152
|
+
chunks = []
|
|
153
|
+
for heading, body in sections:
|
|
154
|
+
cur = ""
|
|
155
|
+
for para in [p.strip() for p in re.split(r"\n\s*\n", body) if p.strip()]:
|
|
156
|
+
for sent in re.split(r"(?<=[。!?.!?])\s+", para):
|
|
157
|
+
cand = (cur + " " + sent).strip() if cur else sent
|
|
158
|
+
if _token_len(cand) > size and cur:
|
|
159
|
+
chunks.append({"heading": heading, "text": cur})
|
|
160
|
+
words = cur.split()
|
|
161
|
+
cur = (" ".join(words[-overlap:]) + " " + sent) if overlap else sent
|
|
162
|
+
else:
|
|
163
|
+
cur = cand
|
|
164
|
+
if cur.strip():
|
|
165
|
+
chunks.append({"heading": heading, "text": cur.strip()})
|
|
166
|
+
return [c for c in chunks if c["text"]]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ── tools ────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
@mcp.tool()
|
|
172
|
+
def convert_to_markdown(path: str) -> str:
|
|
173
|
+
"""Convert a local PDF, DOCX, PPTX or XLSX file to Markdown (tables preserved).
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
path: Absolute path to the file on the user's machine.
|
|
177
|
+
"""
|
|
178
|
+
p = Path(path).expanduser()
|
|
179
|
+
if not p.exists():
|
|
180
|
+
return f"错误:文件不存在 {p}"
|
|
181
|
+
try:
|
|
182
|
+
md = _extract(p)
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
return f"错误:转换失败({exc})"
|
|
185
|
+
if not md.strip():
|
|
186
|
+
return "转换结果为空。如果是扫描版 PDF,请改用 ocr_extract。"
|
|
187
|
+
return md
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@mcp.tool()
|
|
191
|
+
def ocr_extract(path: str, dpi: int = 200, max_pages: int = 50) -> str:
|
|
192
|
+
"""OCR an image or scanned PDF into text. Requires the rapidocr-onnxruntime package.
|
|
193
|
+
|
|
194
|
+
Pages that already carry a text layer are read directly instead of being OCR'd.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
path: Absolute path to an image or PDF.
|
|
198
|
+
dpi: Render DPI for PDF pages (200 is a good default, 300 for fuzzy scans).
|
|
199
|
+
max_pages: Safety cap on how many pages to process.
|
|
200
|
+
"""
|
|
201
|
+
p = Path(path).expanduser()
|
|
202
|
+
if not p.exists():
|
|
203
|
+
return f"错误:文件不存在 {p}"
|
|
204
|
+
try:
|
|
205
|
+
from rapidocr_onnxruntime import RapidOCR
|
|
206
|
+
except ImportError:
|
|
207
|
+
return "错误:未安装 OCR 依赖。请先执行 pip install rapidocr-onnxruntime"
|
|
208
|
+
|
|
209
|
+
engine = RapidOCR()
|
|
210
|
+
ext = p.suffix.lower()
|
|
211
|
+
|
|
212
|
+
if ext == ".pdf":
|
|
213
|
+
import fitz
|
|
214
|
+
|
|
215
|
+
doc = fitz.open(str(p))
|
|
216
|
+
limit = min(len(doc), max_pages) if max_pages > 0 else len(doc)
|
|
217
|
+
parts = []
|
|
218
|
+
for i in range(limit):
|
|
219
|
+
page = doc[i]
|
|
220
|
+
text = page.get_text("text").strip()
|
|
221
|
+
if len(text) > 20:
|
|
222
|
+
parts.append(f"\n<!-- page {i + 1} · text layer -->\n\n{text}")
|
|
223
|
+
continue
|
|
224
|
+
pix = page.get_pixmap(dpi=dpi)
|
|
225
|
+
tmp = Path(str(p) + f".p{i + 1}.png")
|
|
226
|
+
pix.save(str(tmp))
|
|
227
|
+
try:
|
|
228
|
+
result, _ = engine(str(tmp))
|
|
229
|
+
finally:
|
|
230
|
+
tmp.unlink(missing_ok=True)
|
|
231
|
+
body = "\n".join(line[1] for line in (result or []))
|
|
232
|
+
parts.append(f"\n<!-- page {i + 1} · ocr -->\n\n{body}")
|
|
233
|
+
doc.close()
|
|
234
|
+
out = "".join(parts).strip()
|
|
235
|
+
else:
|
|
236
|
+
result, _ = engine(str(p))
|
|
237
|
+
out = "\n".join(line[1] for line in (result or []))
|
|
238
|
+
|
|
239
|
+
return out or "没有识别到文字。"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@mcp.tool()
|
|
243
|
+
def split_chunks(path: str, size: int = 800, overlap: int = 100) -> str:
|
|
244
|
+
"""Split a document into retrieval-sized chunks and return them inline.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
path: Absolute path to a document.
|
|
248
|
+
size: Target tokens per chunk.
|
|
249
|
+
overlap: Tokens of overlap between consecutive chunks.
|
|
250
|
+
"""
|
|
251
|
+
p = Path(path).expanduser()
|
|
252
|
+
if not p.exists():
|
|
253
|
+
return f"错误:文件不存在 {p}"
|
|
254
|
+
try:
|
|
255
|
+
md = p.read_text(encoding="utf-8", errors="replace") if p.suffix.lower() in TEXT_EXT else _extract(p)
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
return f"错误:读取失败({exc})"
|
|
258
|
+
chunks = _chunk(md, max(100, size), max(0, overlap))
|
|
259
|
+
if not chunks:
|
|
260
|
+
return "没有可切分的内容。"
|
|
261
|
+
out = [f"共 {len(chunks)} 块:"]
|
|
262
|
+
for i, c in enumerate(chunks, 1):
|
|
263
|
+
out.append(f"\n--- 块 {i}{' · ' + c['heading'] if c['heading'] else ''} ---\n{c['text']}")
|
|
264
|
+
return "\n".join(out)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@mcp.tool()
|
|
268
|
+
def pdf_merge(inputs: list[str], output: str) -> str:
|
|
269
|
+
"""Merge several PDFs into one, in the order given.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
inputs: Absolute paths of the PDFs to merge.
|
|
273
|
+
output: Where to write the merged PDF.
|
|
274
|
+
"""
|
|
275
|
+
from pypdf import PdfReader, PdfWriter
|
|
276
|
+
|
|
277
|
+
writer, total = PdfWriter(), 0
|
|
278
|
+
for item in inputs:
|
|
279
|
+
p = Path(item).expanduser()
|
|
280
|
+
if not p.exists():
|
|
281
|
+
return f"错误:文件不存在 {p}"
|
|
282
|
+
reader = PdfReader(str(p))
|
|
283
|
+
for page in reader.pages:
|
|
284
|
+
writer.add_page(page)
|
|
285
|
+
total += len(reader.pages)
|
|
286
|
+
out = Path(output).expanduser()
|
|
287
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
288
|
+
with open(out, "wb") as fh:
|
|
289
|
+
writer.write(fh)
|
|
290
|
+
return f"已合并 {len(inputs)} 个文件共 {total} 页 → {out}"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@mcp.tool()
|
|
294
|
+
def pdf_split(path: str, output_dir: str, every: int = 1, page_range: str = "") -> str:
|
|
295
|
+
"""Split a PDF by page count, or extract a page range.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
path: Absolute path to the PDF.
|
|
299
|
+
output_dir: Directory to write the parts into.
|
|
300
|
+
every: When page_range is empty, put this many pages in each output file.
|
|
301
|
+
page_range: Optional "2-5" or "7" to extract just those pages.
|
|
302
|
+
"""
|
|
303
|
+
from pypdf import PdfReader, PdfWriter
|
|
304
|
+
|
|
305
|
+
src = Path(path).expanduser()
|
|
306
|
+
if not src.exists():
|
|
307
|
+
return f"错误:文件不存在 {src}"
|
|
308
|
+
reader = PdfReader(str(src))
|
|
309
|
+
n = len(reader.pages)
|
|
310
|
+
outdir = Path(output_dir).expanduser()
|
|
311
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
312
|
+
|
|
313
|
+
if page_range:
|
|
314
|
+
rng = page_range.replace(" ", "")
|
|
315
|
+
lo, _, hi = rng.partition("-")
|
|
316
|
+
lo = int(lo)
|
|
317
|
+
hi = int(hi) if hi else lo
|
|
318
|
+
if lo < 1 or hi > n or lo > hi:
|
|
319
|
+
return f"错误:页码范围不合法(文档共 {n} 页)"
|
|
320
|
+
w = PdfWriter()
|
|
321
|
+
for i in range(lo - 1, hi):
|
|
322
|
+
w.add_page(reader.pages[i])
|
|
323
|
+
dst = outdir / f"{src.stem}_p{lo}-{hi}.pdf"
|
|
324
|
+
with open(dst, "wb") as fh:
|
|
325
|
+
w.write(fh)
|
|
326
|
+
return f"已提取第 {lo}-{hi} 页 → {dst}"
|
|
327
|
+
|
|
328
|
+
step = max(1, every)
|
|
329
|
+
made = 0
|
|
330
|
+
for start in range(0, n, step):
|
|
331
|
+
w = PdfWriter()
|
|
332
|
+
for i in range(start, min(start + step, n)):
|
|
333
|
+
w.add_page(reader.pages[i])
|
|
334
|
+
dst = outdir / f"{src.stem}_p{start + 1}-{min(start + step, n)}.pdf"
|
|
335
|
+
with open(dst, "wb") as fh:
|
|
336
|
+
w.write(fh)
|
|
337
|
+
made += 1
|
|
338
|
+
return f"已拆成 {made} 个文件 → {outdir}"
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def main() -> None:
|
|
342
|
+
mcp.run()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
if __name__ == "__main__":
|
|
346
|
+
main()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Smoke tests — no network, no OCR model required."""
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
|
|
5
|
+
from doc_toolkit_mcp import server
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_missing_file_is_reported():
|
|
9
|
+
out = server.convert_to_markdown("definitely-not-here.pdf")
|
|
10
|
+
assert "文件不存在" in out
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_chunk_respects_headings():
|
|
14
|
+
md = "## A\n\n第一段内容。\n\n第二段内容。\n\n## B\n\n第三段。"
|
|
15
|
+
chunks = server._chunk(md, 50, 0)
|
|
16
|
+
assert any(c["heading"] == "## A" for c in chunks)
|
|
17
|
+
assert any(c["heading"] == "## B" for c in chunks)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_table_rendering():
|
|
21
|
+
out = server._fmt_table([["a", "b"], ["1", "2"]])
|
|
22
|
+
assert out.splitlines()[0] == "| a | b |"
|
|
23
|
+
assert "| 1 | 2 |" in out
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_pdf_merge_and_split(tmp_path):
|
|
27
|
+
import fitz
|
|
28
|
+
|
|
29
|
+
paths = []
|
|
30
|
+
for name, pages in (("a.pdf", 2), ("b.pdf", 1)):
|
|
31
|
+
doc = fitz.open()
|
|
32
|
+
for _ in range(pages):
|
|
33
|
+
doc.new_page()
|
|
34
|
+
p = tmp_path / name
|
|
35
|
+
doc.save(str(p))
|
|
36
|
+
doc.close()
|
|
37
|
+
paths.append(str(p))
|
|
38
|
+
|
|
39
|
+
merged = tmp_path / "merged.pdf"
|
|
40
|
+
msg = server.pdf_merge(paths, str(merged))
|
|
41
|
+
assert merged.exists() and "3 页" in msg
|
|
42
|
+
|
|
43
|
+
outdir = tmp_path / "parts"
|
|
44
|
+
msg = server.pdf_split(str(merged), str(outdir), every=2)
|
|
45
|
+
assert len(list(outdir.glob("*.pdf"))) == 2
|