doc2md-lite 1.0.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.
- doc2md_lite-1.0.0/CHANGELOG.md +10 -0
- doc2md_lite-1.0.0/LICENSE +9 -0
- doc2md_lite-1.0.0/MANIFEST.in +4 -0
- doc2md_lite-1.0.0/PKG-INFO +170 -0
- doc2md_lite-1.0.0/README.md +138 -0
- doc2md_lite-1.0.0/doc2md_lite.egg-info/PKG-INFO +170 -0
- doc2md_lite-1.0.0/doc2md_lite.egg-info/SOURCES.txt +19 -0
- doc2md_lite-1.0.0/doc2md_lite.egg-info/dependency_links.txt +1 -0
- doc2md_lite-1.0.0/doc2md_lite.egg-info/entry_points.txt +3 -0
- doc2md_lite-1.0.0/doc2md_lite.egg-info/requires.txt +3 -0
- doc2md_lite-1.0.0/doc2md_lite.egg-info/top_level.txt +2 -0
- doc2md_lite-1.0.0/doc2md_lite.py +379 -0
- doc2md_lite-1.0.0/doc2md_lite_mcp.py +43 -0
- doc2md_lite-1.0.0/llms.txt +19 -0
- doc2md_lite-1.0.0/pyproject.toml +47 -0
- doc2md_lite-1.0.0/server.json +24 -0
- doc2md_lite-1.0.0/setup.cfg +4 -0
- doc2md_lite-1.0.0/tests/fixtures/returns-help.html +10 -0
- doc2md_lite-1.0.0/tests/fixtures/store-report.pdf +93 -0
- doc2md_lite-1.0.0/tests/test_lite.py +53 -0
- doc2md_lite-1.0.0/tests/test_mcp.py +37 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
What changed in doc2md-lite, newest first.
|
|
4
|
+
|
|
5
|
+
## 1.0.0 - 2026-11-04
|
|
6
|
+
|
|
7
|
+
- First release.
|
|
8
|
+
- Turns a text PDF or a saved HTML page into clean Markdown. Tables stay Markdown tables.
|
|
9
|
+
- Free MCP server, `doc2md-lite-mcp`, with one tool, `convert_to_markdown`.
|
|
10
|
+
- You can install it from PyPI with `pip install doc2md-lite`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tidybyte
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: doc2md-lite
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Turn a text PDF or HTML file into clean Markdown, with tables kept as Markdown tables. Runs locally. CLI and MCP server.
|
|
5
|
+
Author: Tidy Byte Tools
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://tidybyte.gumroad.com/l/frlkv
|
|
8
|
+
Project-URL: Source, https://github.com/tidybytetools/doc2md-lite
|
|
9
|
+
Project-URL: Issues, https://github.com/tidybytetools/doc2md-lite/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/tidybytetools/doc2md-lite/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: pdf,html,markdown,pdf-to-markdown,html-to-markdown,llm,rag,cli,mcp,mcp-server
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: pdfplumber>=0.10
|
|
29
|
+
Requires-Dist: beautifulsoup4>=4.12
|
|
30
|
+
Requires-Dist: mcp<2,>=1.2
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# doc2md-lite
|
|
34
|
+
|
|
35
|
+
[](https://github.com/tidybytetools/doc2md-lite/blob/main/LICENSE)
|
|
36
|
+
[](https://www.python.org/)
|
|
37
|
+
[](#use-it-from-claude-desktop-or-cursor-mcp)
|
|
38
|
+
|
|
39
|
+
Free tool that turns a text PDF or a saved web page (.html) into clean Markdown. Tables stay real Markdown tables.
|
|
40
|
+
Need Word files, chunks for AI search (RAG), whole folders or page ranges? The paid version does that: **[doc2md on Gumroad](https://tidybyte.gumroad.com/l/frlkv)**
|
|
41
|
+
|
|
42
|
+
Runs on your own computer, so your files are never uploaded anywhere. MIT license. Python 3.10 or newer, on Windows, Mac and Linux.
|
|
43
|
+
|
|
44
|
+
<!-- mcp-name: io.github.tidybytetools/doc2md-lite -->
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
pip install doc2md-lite
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or use pipx, which keeps it in its own space:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
pipx install doc2md-lite
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Want the newest code from GitHub instead?
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
pip install git+https://github.com/tidybytetools/doc2md-lite
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Use it
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
doc2md-lite report.pdf # writes report.md
|
|
68
|
+
doc2md-lite page.html -o page.md
|
|
69
|
+
doc2md-lite report.pdf -o - # print the Markdown on screen
|
|
70
|
+
doc2md-lite report.pdf --tables text # also find tables with no border lines
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Example run:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
Saved: report.md (4 headings, 1 tables)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
What you get from a PDF:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
# Spring Store Report
|
|
83
|
+
|
|
84
|
+
Our team checked every store in March. Most stores met the new goal...
|
|
85
|
+
|
|
86
|
+
## Sales by region
|
|
87
|
+
|
|
88
|
+
| Region | Stores | Sales |
|
|
89
|
+
| --- | --- | --- |
|
|
90
|
+
| North | 12 | $48,200 |
|
|
91
|
+
| South | 9 | $39,950 |
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
What it keeps: headings (found by font size and bold in PDFs), paragraphs, bullet and numbered lists, tables, and in HTML also bold, italic, links and code blocks. From web pages it drops scripts, styles, menus and footers.
|
|
95
|
+
|
|
96
|
+
When you run it in a terminal it ends with one short line about the paid version. `--quiet` turns it off, and it never shows when output is piped or redirected.
|
|
97
|
+
|
|
98
|
+
## Known limits
|
|
99
|
+
|
|
100
|
+
- **Scanned PDFs.** Pages that are just an image (no text layer) are skipped, and the tool tells you which ones.
|
|
101
|
+
- **Headings in PDFs are a best guess** from font size and bold. Odd layouts may need a quick fix.
|
|
102
|
+
- **Two-column PDFs** (like science papers) may mix the columns.
|
|
103
|
+
- **One file at a time.** No folders, no page ranges, no Word files, no RAG chunks. The paid version has those.
|
|
104
|
+
- **Files only.** It does not download web pages. Save the page first.
|
|
105
|
+
|
|
106
|
+
## Lite vs paid
|
|
107
|
+
|
|
108
|
+
| | lite (free) | [doc2md](https://tidybyte.gumroad.com/l/frlkv) |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| Text PDF and HTML to Markdown | yes | yes |
|
|
111
|
+
| Tables kept as Markdown tables | yes | yes |
|
|
112
|
+
| Word (.docx), with bold, italic, links and lists | no | yes |
|
|
113
|
+
| RAG chunks (JSONL with page, heading path, size and overlap) | no | yes |
|
|
114
|
+
| Whole folders, sub-folders too | no | yes |
|
|
115
|
+
| Page ranges and password-protected PDFs | no | yes |
|
|
116
|
+
|
|
117
|
+
## Other good free tools
|
|
118
|
+
|
|
119
|
+
Pick what fits you. [MarkItDown](https://github.com/microsoft/markitdown) reads many more file types. [pandoc](https://pandoc.org/) is great for Word and HTML but does not read PDFs. [pymupdf4llm](https://github.com/pymupdf/RAG) is fast on PDFs but uses the AGPL license. doc2md-lite is small, MIT, keeps PDF tables as tables, and has an MCP server built in.
|
|
120
|
+
|
|
121
|
+
## Use it from Claude Desktop or Cursor (MCP)
|
|
122
|
+
|
|
123
|
+
There is a small free MCP server built in. It gives your AI app one tool, `convert_to_markdown`. It needs [uv](https://docs.astral.sh/uv/).
|
|
124
|
+
|
|
125
|
+
Run it by hand to check it starts (it waits quietly for an app to talk to it):
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
uvx --from doc2md-lite doc2md-lite-mcp
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Claude Desktop (`claude_desktop_config.json`) or Cursor (`~/.cursor/mcp.json`):
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{
|
|
135
|
+
"mcpServers": {
|
|
136
|
+
"doc2md-lite": {
|
|
137
|
+
"command": "uvx",
|
|
138
|
+
"args": ["--from", "doc2md-lite", "doc2md-lite-mcp"]
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Tool: `convert_to_markdown(path, tables="lines", output_path="")`. It returns the Markdown text. If you set `output_path`, it saves the file there and returns the path instead.
|
|
145
|
+
|
|
146
|
+
## For AI agents
|
|
147
|
+
|
|
148
|
+
- **Use when:** you have a local PDF with real text (you can select the words) or a saved HTML file, and you want Markdown to read or store.
|
|
149
|
+
- **Don't use when:** the PDF is a scan, the file is .docx, or you need many files or chunks with page numbers. Use the paid version for those.
|
|
150
|
+
- **Input:** a file path, plus optional `tables` (`lines`, `text`, `off`).
|
|
151
|
+
- **Output:** Markdown text. Tables are Markdown tables with the first row as the header.
|
|
152
|
+
- CLI exit code is 0 on success and 1 on error (message on stderr, or `{"ok": false, "error": ...}` with `--json`). See also [`llms.txt`](https://github.com/tidybytetools/doc2md-lite/blob/main/llms.txt).
|
|
153
|
+
|
|
154
|
+
## Python
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from doc2md_lite import convert
|
|
158
|
+
md, info = convert("report.pdf") # info: type, tables, headings, scanned_pages
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Tests
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
pip install pdfplumber beautifulsoup4 reportlab "mcp<2"
|
|
165
|
+
python3 -m unittest discover tests
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# doc2md-lite
|
|
2
|
+
|
|
3
|
+
[](https://github.com/tidybytetools/doc2md-lite/blob/main/LICENSE)
|
|
4
|
+
[](https://www.python.org/)
|
|
5
|
+
[](#use-it-from-claude-desktop-or-cursor-mcp)
|
|
6
|
+
|
|
7
|
+
Free tool that turns a text PDF or a saved web page (.html) into clean Markdown. Tables stay real Markdown tables.
|
|
8
|
+
Need Word files, chunks for AI search (RAG), whole folders or page ranges? The paid version does that: **[doc2md on Gumroad](https://tidybyte.gumroad.com/l/frlkv)**
|
|
9
|
+
|
|
10
|
+
Runs on your own computer, so your files are never uploaded anywhere. MIT license. Python 3.10 or newer, on Windows, Mac and Linux.
|
|
11
|
+
|
|
12
|
+
<!-- mcp-name: io.github.tidybytetools/doc2md-lite -->
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
pip install doc2md-lite
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or use pipx, which keeps it in its own space:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
pipx install doc2md-lite
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Want the newest code from GitHub instead?
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
pip install git+https://github.com/tidybytetools/doc2md-lite
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Use it
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
doc2md-lite report.pdf # writes report.md
|
|
36
|
+
doc2md-lite page.html -o page.md
|
|
37
|
+
doc2md-lite report.pdf -o - # print the Markdown on screen
|
|
38
|
+
doc2md-lite report.pdf --tables text # also find tables with no border lines
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Example run:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
Saved: report.md (4 headings, 1 tables)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
What you get from a PDF:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
# Spring Store Report
|
|
51
|
+
|
|
52
|
+
Our team checked every store in March. Most stores met the new goal...
|
|
53
|
+
|
|
54
|
+
## Sales by region
|
|
55
|
+
|
|
56
|
+
| Region | Stores | Sales |
|
|
57
|
+
| --- | --- | --- |
|
|
58
|
+
| North | 12 | $48,200 |
|
|
59
|
+
| South | 9 | $39,950 |
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
What it keeps: headings (found by font size and bold in PDFs), paragraphs, bullet and numbered lists, tables, and in HTML also bold, italic, links and code blocks. From web pages it drops scripts, styles, menus and footers.
|
|
63
|
+
|
|
64
|
+
When you run it in a terminal it ends with one short line about the paid version. `--quiet` turns it off, and it never shows when output is piped or redirected.
|
|
65
|
+
|
|
66
|
+
## Known limits
|
|
67
|
+
|
|
68
|
+
- **Scanned PDFs.** Pages that are just an image (no text layer) are skipped, and the tool tells you which ones.
|
|
69
|
+
- **Headings in PDFs are a best guess** from font size and bold. Odd layouts may need a quick fix.
|
|
70
|
+
- **Two-column PDFs** (like science papers) may mix the columns.
|
|
71
|
+
- **One file at a time.** No folders, no page ranges, no Word files, no RAG chunks. The paid version has those.
|
|
72
|
+
- **Files only.** It does not download web pages. Save the page first.
|
|
73
|
+
|
|
74
|
+
## Lite vs paid
|
|
75
|
+
|
|
76
|
+
| | lite (free) | [doc2md](https://tidybyte.gumroad.com/l/frlkv) |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| Text PDF and HTML to Markdown | yes | yes |
|
|
79
|
+
| Tables kept as Markdown tables | yes | yes |
|
|
80
|
+
| Word (.docx), with bold, italic, links and lists | no | yes |
|
|
81
|
+
| RAG chunks (JSONL with page, heading path, size and overlap) | no | yes |
|
|
82
|
+
| Whole folders, sub-folders too | no | yes |
|
|
83
|
+
| Page ranges and password-protected PDFs | no | yes |
|
|
84
|
+
|
|
85
|
+
## Other good free tools
|
|
86
|
+
|
|
87
|
+
Pick what fits you. [MarkItDown](https://github.com/microsoft/markitdown) reads many more file types. [pandoc](https://pandoc.org/) is great for Word and HTML but does not read PDFs. [pymupdf4llm](https://github.com/pymupdf/RAG) is fast on PDFs but uses the AGPL license. doc2md-lite is small, MIT, keeps PDF tables as tables, and has an MCP server built in.
|
|
88
|
+
|
|
89
|
+
## Use it from Claude Desktop or Cursor (MCP)
|
|
90
|
+
|
|
91
|
+
There is a small free MCP server built in. It gives your AI app one tool, `convert_to_markdown`. It needs [uv](https://docs.astral.sh/uv/).
|
|
92
|
+
|
|
93
|
+
Run it by hand to check it starts (it waits quietly for an app to talk to it):
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
uvx --from doc2md-lite doc2md-lite-mcp
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Claude Desktop (`claude_desktop_config.json`) or Cursor (`~/.cursor/mcp.json`):
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"mcpServers": {
|
|
104
|
+
"doc2md-lite": {
|
|
105
|
+
"command": "uvx",
|
|
106
|
+
"args": ["--from", "doc2md-lite", "doc2md-lite-mcp"]
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Tool: `convert_to_markdown(path, tables="lines", output_path="")`. It returns the Markdown text. If you set `output_path`, it saves the file there and returns the path instead.
|
|
113
|
+
|
|
114
|
+
## For AI agents
|
|
115
|
+
|
|
116
|
+
- **Use when:** you have a local PDF with real text (you can select the words) or a saved HTML file, and you want Markdown to read or store.
|
|
117
|
+
- **Don't use when:** the PDF is a scan, the file is .docx, or you need many files or chunks with page numbers. Use the paid version for those.
|
|
118
|
+
- **Input:** a file path, plus optional `tables` (`lines`, `text`, `off`).
|
|
119
|
+
- **Output:** Markdown text. Tables are Markdown tables with the first row as the header.
|
|
120
|
+
- CLI exit code is 0 on success and 1 on error (message on stderr, or `{"ok": false, "error": ...}` with `--json`). See also [`llms.txt`](https://github.com/tidybytetools/doc2md-lite/blob/main/llms.txt).
|
|
121
|
+
|
|
122
|
+
## Python
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from doc2md_lite import convert
|
|
126
|
+
md, info = convert("report.pdf") # info: type, tables, headings, scanned_pages
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Tests
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
pip install pdfplumber beautifulsoup4 reportlab "mcp<2"
|
|
133
|
+
python3 -m unittest discover tests
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: doc2md-lite
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Turn a text PDF or HTML file into clean Markdown, with tables kept as Markdown tables. Runs locally. CLI and MCP server.
|
|
5
|
+
Author: Tidy Byte Tools
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://tidybyte.gumroad.com/l/frlkv
|
|
8
|
+
Project-URL: Source, https://github.com/tidybytetools/doc2md-lite
|
|
9
|
+
Project-URL: Issues, https://github.com/tidybytetools/doc2md-lite/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/tidybytetools/doc2md-lite/blob/main/CHANGELOG.md
|
|
11
|
+
Keywords: pdf,html,markdown,pdf-to-markdown,html-to-markdown,llm,rag,cli,mcp,mcp-server
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: pdfplumber>=0.10
|
|
29
|
+
Requires-Dist: beautifulsoup4>=4.12
|
|
30
|
+
Requires-Dist: mcp<2,>=1.2
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# doc2md-lite
|
|
34
|
+
|
|
35
|
+
[](https://github.com/tidybytetools/doc2md-lite/blob/main/LICENSE)
|
|
36
|
+
[](https://www.python.org/)
|
|
37
|
+
[](#use-it-from-claude-desktop-or-cursor-mcp)
|
|
38
|
+
|
|
39
|
+
Free tool that turns a text PDF or a saved web page (.html) into clean Markdown. Tables stay real Markdown tables.
|
|
40
|
+
Need Word files, chunks for AI search (RAG), whole folders or page ranges? The paid version does that: **[doc2md on Gumroad](https://tidybyte.gumroad.com/l/frlkv)**
|
|
41
|
+
|
|
42
|
+
Runs on your own computer, so your files are never uploaded anywhere. MIT license. Python 3.10 or newer, on Windows, Mac and Linux.
|
|
43
|
+
|
|
44
|
+
<!-- mcp-name: io.github.tidybytetools/doc2md-lite -->
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
pip install doc2md-lite
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or use pipx, which keeps it in its own space:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
pipx install doc2md-lite
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Want the newest code from GitHub instead?
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
pip install git+https://github.com/tidybytetools/doc2md-lite
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Use it
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
doc2md-lite report.pdf # writes report.md
|
|
68
|
+
doc2md-lite page.html -o page.md
|
|
69
|
+
doc2md-lite report.pdf -o - # print the Markdown on screen
|
|
70
|
+
doc2md-lite report.pdf --tables text # also find tables with no border lines
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Example run:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
Saved: report.md (4 headings, 1 tables)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
What you get from a PDF:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
# Spring Store Report
|
|
83
|
+
|
|
84
|
+
Our team checked every store in March. Most stores met the new goal...
|
|
85
|
+
|
|
86
|
+
## Sales by region
|
|
87
|
+
|
|
88
|
+
| Region | Stores | Sales |
|
|
89
|
+
| --- | --- | --- |
|
|
90
|
+
| North | 12 | $48,200 |
|
|
91
|
+
| South | 9 | $39,950 |
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
What it keeps: headings (found by font size and bold in PDFs), paragraphs, bullet and numbered lists, tables, and in HTML also bold, italic, links and code blocks. From web pages it drops scripts, styles, menus and footers.
|
|
95
|
+
|
|
96
|
+
When you run it in a terminal it ends with one short line about the paid version. `--quiet` turns it off, and it never shows when output is piped or redirected.
|
|
97
|
+
|
|
98
|
+
## Known limits
|
|
99
|
+
|
|
100
|
+
- **Scanned PDFs.** Pages that are just an image (no text layer) are skipped, and the tool tells you which ones.
|
|
101
|
+
- **Headings in PDFs are a best guess** from font size and bold. Odd layouts may need a quick fix.
|
|
102
|
+
- **Two-column PDFs** (like science papers) may mix the columns.
|
|
103
|
+
- **One file at a time.** No folders, no page ranges, no Word files, no RAG chunks. The paid version has those.
|
|
104
|
+
- **Files only.** It does not download web pages. Save the page first.
|
|
105
|
+
|
|
106
|
+
## Lite vs paid
|
|
107
|
+
|
|
108
|
+
| | lite (free) | [doc2md](https://tidybyte.gumroad.com/l/frlkv) |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| Text PDF and HTML to Markdown | yes | yes |
|
|
111
|
+
| Tables kept as Markdown tables | yes | yes |
|
|
112
|
+
| Word (.docx), with bold, italic, links and lists | no | yes |
|
|
113
|
+
| RAG chunks (JSONL with page, heading path, size and overlap) | no | yes |
|
|
114
|
+
| Whole folders, sub-folders too | no | yes |
|
|
115
|
+
| Page ranges and password-protected PDFs | no | yes |
|
|
116
|
+
|
|
117
|
+
## Other good free tools
|
|
118
|
+
|
|
119
|
+
Pick what fits you. [MarkItDown](https://github.com/microsoft/markitdown) reads many more file types. [pandoc](https://pandoc.org/) is great for Word and HTML but does not read PDFs. [pymupdf4llm](https://github.com/pymupdf/RAG) is fast on PDFs but uses the AGPL license. doc2md-lite is small, MIT, keeps PDF tables as tables, and has an MCP server built in.
|
|
120
|
+
|
|
121
|
+
## Use it from Claude Desktop or Cursor (MCP)
|
|
122
|
+
|
|
123
|
+
There is a small free MCP server built in. It gives your AI app one tool, `convert_to_markdown`. It needs [uv](https://docs.astral.sh/uv/).
|
|
124
|
+
|
|
125
|
+
Run it by hand to check it starts (it waits quietly for an app to talk to it):
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
uvx --from doc2md-lite doc2md-lite-mcp
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Claude Desktop (`claude_desktop_config.json`) or Cursor (`~/.cursor/mcp.json`):
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{
|
|
135
|
+
"mcpServers": {
|
|
136
|
+
"doc2md-lite": {
|
|
137
|
+
"command": "uvx",
|
|
138
|
+
"args": ["--from", "doc2md-lite", "doc2md-lite-mcp"]
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Tool: `convert_to_markdown(path, tables="lines", output_path="")`. It returns the Markdown text. If you set `output_path`, it saves the file there and returns the path instead.
|
|
145
|
+
|
|
146
|
+
## For AI agents
|
|
147
|
+
|
|
148
|
+
- **Use when:** you have a local PDF with real text (you can select the words) or a saved HTML file, and you want Markdown to read or store.
|
|
149
|
+
- **Don't use when:** the PDF is a scan, the file is .docx, or you need many files or chunks with page numbers. Use the paid version for those.
|
|
150
|
+
- **Input:** a file path, plus optional `tables` (`lines`, `text`, `off`).
|
|
151
|
+
- **Output:** Markdown text. Tables are Markdown tables with the first row as the header.
|
|
152
|
+
- CLI exit code is 0 on success and 1 on error (message on stderr, or `{"ok": false, "error": ...}` with `--json`). See also [`llms.txt`](https://github.com/tidybytetools/doc2md-lite/blob/main/llms.txt).
|
|
153
|
+
|
|
154
|
+
## Python
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from doc2md_lite import convert
|
|
158
|
+
md, info = convert("report.pdf") # info: type, tables, headings, scanned_pages
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Tests
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
pip install pdfplumber beautifulsoup4 reportlab "mcp<2"
|
|
165
|
+
python3 -m unittest discover tests
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
CHANGELOG.md
|
|
2
|
+
LICENSE
|
|
3
|
+
MANIFEST.in
|
|
4
|
+
README.md
|
|
5
|
+
doc2md_lite.py
|
|
6
|
+
doc2md_lite_mcp.py
|
|
7
|
+
llms.txt
|
|
8
|
+
pyproject.toml
|
|
9
|
+
server.json
|
|
10
|
+
doc2md_lite.egg-info/PKG-INFO
|
|
11
|
+
doc2md_lite.egg-info/SOURCES.txt
|
|
12
|
+
doc2md_lite.egg-info/dependency_links.txt
|
|
13
|
+
doc2md_lite.egg-info/entry_points.txt
|
|
14
|
+
doc2md_lite.egg-info/requires.txt
|
|
15
|
+
doc2md_lite.egg-info/top_level.txt
|
|
16
|
+
tests/test_lite.py
|
|
17
|
+
tests/test_mcp.py
|
|
18
|
+
tests/fixtures/returns-help.html
|
|
19
|
+
tests/fixtures/store-report.pdf
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""doc2md-lite: turn a text PDF or a saved HTML page into clean Markdown. Tables stay Markdown tables.
|
|
3
|
+
Free, MIT license. Runs offline.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
doc2md-lite report.pdf # writes report.md
|
|
7
|
+
doc2md-lite page.html -o page.md
|
|
8
|
+
doc2md-lite page.html -o - # print to screen
|
|
9
|
+
|
|
10
|
+
Paid version (Word files, RAG chunks, whole folders, page picking): https://tidybyte.gumroad.com/l/frlkv
|
|
11
|
+
"""
|
|
12
|
+
import argparse, json, os, re, sys
|
|
13
|
+
from collections import Counter
|
|
14
|
+
|
|
15
|
+
__version__ = "1.0.0"
|
|
16
|
+
PAID_URL = "https://tidybyte.gumroad.com/l/frlkv"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------- shared helpers ----------
|
|
20
|
+
|
|
21
|
+
def parse_pages(spec, total):
|
|
22
|
+
"""'1,3-5' -> [1,3,4,5] (1-based). None means every page."""
|
|
23
|
+
if not spec:
|
|
24
|
+
return list(range(1, total + 1))
|
|
25
|
+
pages = []
|
|
26
|
+
for part in spec.split(","):
|
|
27
|
+
part = part.strip()
|
|
28
|
+
if not part:
|
|
29
|
+
continue
|
|
30
|
+
if "-" in part:
|
|
31
|
+
a, b = part.split("-", 1)
|
|
32
|
+
pages.extend(range(int(a) if a else 1, (int(b) if b else total) + 1))
|
|
33
|
+
else:
|
|
34
|
+
pages.append(int(part))
|
|
35
|
+
bad = [p for p in pages if p < 1 or p > total]
|
|
36
|
+
if bad:
|
|
37
|
+
raise ValueError(f"page {bad[0]} is out of range (file has {total} pages)")
|
|
38
|
+
return sorted(set(pages))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _clean_cell(c):
|
|
42
|
+
if c is None:
|
|
43
|
+
return ""
|
|
44
|
+
return re.sub(r"\s+", " ", str(c)).strip()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def clean_table(rows):
|
|
48
|
+
rows = [[_clean_cell(c) for c in r] for r in rows]
|
|
49
|
+
rows = [r for r in rows if any(r)]
|
|
50
|
+
if not rows:
|
|
51
|
+
return []
|
|
52
|
+
width = max(len(r) for r in rows)
|
|
53
|
+
rows = [r + [""] * (width - len(r)) for r in rows]
|
|
54
|
+
keep = [i for i in range(width) if any(r[i] for r in rows)]
|
|
55
|
+
return [[r[i] for i in keep] for r in rows]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def md_table(rows):
|
|
59
|
+
"""Rows (first row = header) -> Markdown table text."""
|
|
60
|
+
rows = clean_table(rows)
|
|
61
|
+
if not rows:
|
|
62
|
+
return ""
|
|
63
|
+
esc = lambda c: c.replace("\\", "\\\\").replace("|", "\\|")
|
|
64
|
+
head = rows[0]
|
|
65
|
+
out = ["| " + " | ".join(esc(c) for c in head) + " |",
|
|
66
|
+
"|" + "|".join(" --- " for _ in head) + "|"]
|
|
67
|
+
out += ["| " + " | ".join(esc(c) for c in r) + " |" for r in rows[1:]]
|
|
68
|
+
return "\n".join(out)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def block(kind, text, page=None, level=0):
|
|
72
|
+
return {"kind": kind, "text": text, "page": page, "level": level}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def blocks_to_md(blocks):
|
|
76
|
+
out = []
|
|
77
|
+
for b in blocks:
|
|
78
|
+
if b["kind"] == "heading":
|
|
79
|
+
out.append("#" * b["level"] + " " + b["text"])
|
|
80
|
+
else:
|
|
81
|
+
out.append(b["text"])
|
|
82
|
+
md = "\n\n".join(x.strip("\n") for x in out if x.strip())
|
|
83
|
+
md = re.sub(r"\n{3,}", "\n\n", md)
|
|
84
|
+
return md.strip() + "\n"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------- PDF ----------
|
|
88
|
+
|
|
89
|
+
BULLET_RE = re.compile(r"^\s*[\u2022\u25cf\u25aa\u2013\u00b7*\-]\s+")
|
|
90
|
+
NUM_RE = re.compile(r"^\s*(\d{1,3})[.)]\s+")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _inside(obj, boxes):
|
|
94
|
+
x = (obj["x0"] + obj["x1"]) / 2
|
|
95
|
+
y = (obj["top"] + obj["bottom"]) / 2
|
|
96
|
+
return any(b[0] <= x <= b[2] and b[1] <= y <= b[3] for b in boxes)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def pdf_blocks(path, pages=None, tables="lines", password=None):
|
|
100
|
+
"""Read a text PDF into blocks. tables: 'lines' (ruled tables), 'text' (also space-aligned), 'off'."""
|
|
101
|
+
import pdfplumber
|
|
102
|
+
out = []
|
|
103
|
+
with pdfplumber.open(path, password=password) as pdf:
|
|
104
|
+
nums = parse_pages(pages, len(pdf.pages))
|
|
105
|
+
sizes = Counter()
|
|
106
|
+
for pno in nums: # body size = the most common font size
|
|
107
|
+
for ch in pdf.pages[pno - 1].chars:
|
|
108
|
+
sizes[round(ch["size"], 1)] += 1
|
|
109
|
+
body = sizes.most_common(1)[0][0] if sizes else 10
|
|
110
|
+
bigger = sorted({s for s in sizes if s >= body * 1.15}, reverse=True)[:3]
|
|
111
|
+
level_of = {s: i + 1 for i, s in enumerate(bigger)}
|
|
112
|
+
scanned = []
|
|
113
|
+
for pno in nums:
|
|
114
|
+
page = pdf.pages[pno - 1]
|
|
115
|
+
if not page.chars:
|
|
116
|
+
scanned.append(pno)
|
|
117
|
+
continue
|
|
118
|
+
items = []
|
|
119
|
+
boxes = []
|
|
120
|
+
if tables != "off":
|
|
121
|
+
st = {"vertical_strategy": "lines", "horizontal_strategy": "lines"}
|
|
122
|
+
found = page.find_tables(st)
|
|
123
|
+
if not found and tables == "text":
|
|
124
|
+
found = page.find_tables({"vertical_strategy": "text", "horizontal_strategy": "text"})
|
|
125
|
+
for t in found:
|
|
126
|
+
rows = clean_table(t.extract())
|
|
127
|
+
if len(rows) >= 2 or (rows and tables == "lines" and len(rows[0]) > 1):
|
|
128
|
+
boxes.append(t.bbox)
|
|
129
|
+
items.append((t.bbox[1], block("table", md_table(rows), pno)))
|
|
130
|
+
text_page = page.filter(lambda o: o.get("object_type") != "char" or not _inside(o, boxes)) if boxes else page
|
|
131
|
+
lines = text_page.extract_text_lines(return_chars=True)
|
|
132
|
+
para, last = [], None
|
|
133
|
+
def flush():
|
|
134
|
+
if para:
|
|
135
|
+
items.append((para[0][0], block("para", " ".join(t for _, t in para), pno)))
|
|
136
|
+
para.clear()
|
|
137
|
+
for ln in lines:
|
|
138
|
+
txt = re.sub(r"\(cid:\d+\)", "", ln["text"].replace("(cid:127)", "\u2022")).strip()
|
|
139
|
+
if not txt:
|
|
140
|
+
continue
|
|
141
|
+
size = round(Counter(round(c["size"], 1) for c in ln["chars"]).most_common(1)[0][0], 1)
|
|
142
|
+
bold = sum("Bold" in c.get("fontname", "") for c in ln["chars"]) > len(ln["chars"]) * 0.8
|
|
143
|
+
lvl = level_of.get(size)
|
|
144
|
+
if lvl is None and bold and len(txt) < 80 and not txt.endswith((".", ",", ":")):
|
|
145
|
+
lvl = min(len(bigger) + 1, 6)
|
|
146
|
+
if lvl:
|
|
147
|
+
flush()
|
|
148
|
+
items.append((ln["top"], block("heading", txt, pno, lvl)))
|
|
149
|
+
last = None
|
|
150
|
+
continue
|
|
151
|
+
m = BULLET_RE.match(txt) or NUM_RE.match(txt)
|
|
152
|
+
if m:
|
|
153
|
+
flush()
|
|
154
|
+
mark = (m.group(1) + ".") if m.re is NUM_RE else "-"
|
|
155
|
+
items.append((ln["top"], block("list", f"{mark} {txt[m.end():].strip()}", pno)))
|
|
156
|
+
last = ln
|
|
157
|
+
continue
|
|
158
|
+
gap = (ln["top"] - last["bottom"]) if last else 0
|
|
159
|
+
if last and gap > size * 0.9:
|
|
160
|
+
flush()
|
|
161
|
+
if para and para[-1][1].endswith("-") and not para[-1][1].endswith(" -"):
|
|
162
|
+
para[-1] = (para[-1][0], para[-1][1][:-1] + txt) # re-join a split word
|
|
163
|
+
else:
|
|
164
|
+
para.append((ln["top"], txt))
|
|
165
|
+
last = ln
|
|
166
|
+
flush()
|
|
167
|
+
items.sort(key=lambda x: x[0])
|
|
168
|
+
merged = []
|
|
169
|
+
for _, b in items: # glue list items into one list block
|
|
170
|
+
if merged and b["kind"] == "list" and merged[-1]["kind"] == "list" and merged[-1]["page"] == b["page"]:
|
|
171
|
+
merged[-1]["text"] += "\n" + b["text"]
|
|
172
|
+
else:
|
|
173
|
+
merged.append(b)
|
|
174
|
+
out.extend(merged)
|
|
175
|
+
return out, scanned
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------- HTML ----------
|
|
179
|
+
|
|
180
|
+
SKIP_TAGS = {"script", "style", "noscript", "head", "template", "svg", "iframe", "form", "button"}
|
|
181
|
+
CHROME_TAGS = {"nav", "footer", "aside"}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _inline(node):
|
|
185
|
+
return _inline_nodes(node.children)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _inline_nodes(children):
|
|
189
|
+
from bs4 import NavigableString, Comment
|
|
190
|
+
out = []
|
|
191
|
+
for c in children:
|
|
192
|
+
if isinstance(c, Comment):
|
|
193
|
+
continue
|
|
194
|
+
if isinstance(c, NavigableString):
|
|
195
|
+
out.append(re.sub(r"\s+", " ", str(c)))
|
|
196
|
+
continue
|
|
197
|
+
n = c.name
|
|
198
|
+
if n in SKIP_TAGS:
|
|
199
|
+
continue
|
|
200
|
+
inner = _inline(c)
|
|
201
|
+
core = inner.strip()
|
|
202
|
+
if n == "br":
|
|
203
|
+
out.append(" \n")
|
|
204
|
+
elif n in ("strong", "b") and core:
|
|
205
|
+
out.append(inner.replace(core, f"**{core}**", 1))
|
|
206
|
+
elif n in ("em", "i") and core:
|
|
207
|
+
out.append(inner.replace(core, f"*{core}*", 1))
|
|
208
|
+
elif n == "code" and core:
|
|
209
|
+
out.append(f"`{core}`")
|
|
210
|
+
elif n == "a" and core and c.get("href") and not c["href"].startswith(("javascript:", "#")):
|
|
211
|
+
out.append(f"[{core}]({c['href']})")
|
|
212
|
+
elif n == "img":
|
|
213
|
+
alt = c.get("alt", "").strip()
|
|
214
|
+
if alt:
|
|
215
|
+
out.append(f"})")
|
|
216
|
+
else:
|
|
217
|
+
out.append(inner)
|
|
218
|
+
return "".join(out)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _html_list(node, depth=0):
|
|
222
|
+
lines, i = [], 1
|
|
223
|
+
for li in node.find_all("li", recursive=False):
|
|
224
|
+
subs = [s.extract() for s in li.find_all(["ul", "ol"], recursive=False)]
|
|
225
|
+
mark = f"{i}." if node.name == "ol" else "-"
|
|
226
|
+
lines.append(" " * depth + f"{mark} " + re.sub(r"\s+", " ", _inline(li)).strip())
|
|
227
|
+
for s in subs:
|
|
228
|
+
lines.extend(_html_list(s, depth + 1))
|
|
229
|
+
i += 1
|
|
230
|
+
return lines
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _html_walk(node, out):
|
|
234
|
+
from bs4 import NavigableString, Tag
|
|
235
|
+
buf = []
|
|
236
|
+
def flush():
|
|
237
|
+
t = re.sub(r"[ \t]+", " ", "".join(buf)).strip()
|
|
238
|
+
if t:
|
|
239
|
+
out.append(block("para", t))
|
|
240
|
+
buf.clear()
|
|
241
|
+
for c in node.children:
|
|
242
|
+
if not isinstance(c, Tag):
|
|
243
|
+
if isinstance(c, NavigableString) and type(c).__name__ == "NavigableString":
|
|
244
|
+
buf.append(str(c))
|
|
245
|
+
continue
|
|
246
|
+
n = c.name
|
|
247
|
+
if n in SKIP_TAGS:
|
|
248
|
+
continue
|
|
249
|
+
if re.fullmatch(r"h[1-6]", n):
|
|
250
|
+
flush()
|
|
251
|
+
t = re.sub(r"\s+", " ", c.get_text(" ")).strip()
|
|
252
|
+
if t:
|
|
253
|
+
out.append(block("heading", t, level=int(n[1])))
|
|
254
|
+
elif n == "p":
|
|
255
|
+
flush()
|
|
256
|
+
t = re.sub(r"[ \t]+", " ", _inline(c)).strip()
|
|
257
|
+
if t:
|
|
258
|
+
out.append(block("para", t))
|
|
259
|
+
elif n in ("ul", "ol"):
|
|
260
|
+
flush()
|
|
261
|
+
lines = _html_list(c)
|
|
262
|
+
if lines:
|
|
263
|
+
out.append(block("list", "\n".join(lines)))
|
|
264
|
+
elif n == "table":
|
|
265
|
+
flush()
|
|
266
|
+
rows = []
|
|
267
|
+
for tr in c.find_all("tr"):
|
|
268
|
+
if tr.find_parent("table") is not c:
|
|
269
|
+
continue
|
|
270
|
+
rows.append([re.sub(r"\s+", " ", _inline(td)).strip() for td in tr.find_all(["th", "td"], recursive=False)])
|
|
271
|
+
t = md_table(rows)
|
|
272
|
+
if t:
|
|
273
|
+
out.append(block("table", t))
|
|
274
|
+
elif n == "pre":
|
|
275
|
+
flush()
|
|
276
|
+
code = c.get_text()
|
|
277
|
+
lang = ""
|
|
278
|
+
cd = c.find("code")
|
|
279
|
+
if cd:
|
|
280
|
+
for cls in cd.get("class", []):
|
|
281
|
+
if cls.startswith("language-"):
|
|
282
|
+
lang = cls[9:]
|
|
283
|
+
out.append(block("code", f"```{lang}\n{code.strip(chr(10))}\n```"))
|
|
284
|
+
elif n == "blockquote":
|
|
285
|
+
flush()
|
|
286
|
+
sub = []
|
|
287
|
+
_html_walk(c, sub)
|
|
288
|
+
txt = blocks_to_md(sub).strip()
|
|
289
|
+
if txt:
|
|
290
|
+
out.append(block("para", "\n".join("> " + l if l else ">" for l in txt.split("\n"))))
|
|
291
|
+
elif n == "hr":
|
|
292
|
+
flush()
|
|
293
|
+
out.append(block("para", "---"))
|
|
294
|
+
elif n in ("div", "section", "article", "main", "body", "html", "header", "figure", "nav", "footer", "aside", "dl", "details"):
|
|
295
|
+
flush()
|
|
296
|
+
_html_walk(c, out)
|
|
297
|
+
else:
|
|
298
|
+
buf.append(_inline_nodes([c]))
|
|
299
|
+
flush()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def html_blocks(path_or_text, keep_chrome=False, is_text=False):
|
|
303
|
+
from bs4 import BeautifulSoup
|
|
304
|
+
if is_text:
|
|
305
|
+
raw = path_or_text
|
|
306
|
+
else:
|
|
307
|
+
with open(path_or_text, "rb") as f:
|
|
308
|
+
raw = f.read()
|
|
309
|
+
soup = BeautifulSoup(raw, "html.parser")
|
|
310
|
+
title = soup.title.get_text(" ", strip=True) if soup.title else ""
|
|
311
|
+
for t in soup.find_all(list(SKIP_TAGS)):
|
|
312
|
+
t.decompose()
|
|
313
|
+
if not keep_chrome:
|
|
314
|
+
for t in soup.find_all(list(CHROME_TAGS)):
|
|
315
|
+
t.decompose()
|
|
316
|
+
root = soup.find("main") or soup.find("article") or soup.body or soup
|
|
317
|
+
out = []
|
|
318
|
+
_html_walk(root, out)
|
|
319
|
+
if not any(b["kind"] == "heading" and b["level"] == 1 for b in out) and title:
|
|
320
|
+
out.insert(0, block("heading", title, level=1))
|
|
321
|
+
return out
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ---------- top level ----------
|
|
325
|
+
|
|
326
|
+
def convert(path, tables="lines"):
|
|
327
|
+
"""Return (markdown, info) for one .pdf or .html file."""
|
|
328
|
+
ext = os.path.splitext(path)[1].lower()
|
|
329
|
+
info = {"type": ext[1:], "scanned_pages": []}
|
|
330
|
+
if ext == ".pdf":
|
|
331
|
+
blocks, info["scanned_pages"] = pdf_blocks(path, None, tables)
|
|
332
|
+
elif ext in (".html", ".htm"):
|
|
333
|
+
blocks = html_blocks(path)
|
|
334
|
+
else:
|
|
335
|
+
raise ValueError(f"lite reads .pdf and .html only (got '{ext}'). Word files need the paid version: {PAID_URL}")
|
|
336
|
+
info["tables"] = sum(1 for b in blocks if b["kind"] == "table")
|
|
337
|
+
info["headings"] = sum(1 for b in blocks if b["kind"] == "heading")
|
|
338
|
+
return blocks_to_md(blocks), info
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def main(argv=None):
|
|
342
|
+
ap = argparse.ArgumentParser(description="Turn a text PDF or HTML file into clean Markdown.")
|
|
343
|
+
ap.add_argument("file", help="a .pdf or .html file")
|
|
344
|
+
ap.add_argument("-o", "--output", help="output .md file, or - for the screen. Default: next to the input")
|
|
345
|
+
ap.add_argument("--tables", choices=["lines", "text", "off"], default="lines",
|
|
346
|
+
help="PDF tables: lines (with borders, default), text (also lined up by spaces), off")
|
|
347
|
+
ap.add_argument("--json", action="store_true", help="print a JSON summary (for scripts and agents)")
|
|
348
|
+
ap.add_argument("-q", "--quiet", action="store_true", help="no tip about the paid version")
|
|
349
|
+
ap.add_argument("--version", action="version", version=__version__)
|
|
350
|
+
a = ap.parse_args(argv)
|
|
351
|
+
try:
|
|
352
|
+
if not os.path.isfile(a.file):
|
|
353
|
+
raise FileNotFoundError(f"file not found: {a.file}")
|
|
354
|
+
md, info = convert(a.file, a.tables)
|
|
355
|
+
out = a.output or os.path.splitext(a.file)[0] + ".md"
|
|
356
|
+
if out == "-":
|
|
357
|
+
sys.stdout.write(md)
|
|
358
|
+
return 0
|
|
359
|
+
with open(out, "w", encoding="utf-8") as f:
|
|
360
|
+
f.write(md)
|
|
361
|
+
if a.json:
|
|
362
|
+
print(json.dumps({"ok": True, "input": a.file, "output": out, "chars": len(md), **info}))
|
|
363
|
+
else:
|
|
364
|
+
print(f"Saved: {out} ({info['headings']} headings, {info['tables']} tables)")
|
|
365
|
+
if info["scanned_pages"]:
|
|
366
|
+
print(f"Note: pages {info['scanned_pages']} have no text (scanned) and were skipped.")
|
|
367
|
+
if not a.quiet and sys.stdout.isatty():
|
|
368
|
+
print(f"Need Word files, RAG chunks or whole folders? {PAID_URL}")
|
|
369
|
+
return 0
|
|
370
|
+
except Exception as e:
|
|
371
|
+
if a.json:
|
|
372
|
+
print(json.dumps({"ok": False, "error": str(e)}))
|
|
373
|
+
else:
|
|
374
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
375
|
+
return 1
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
if __name__ == "__main__":
|
|
379
|
+
sys.exit(main())
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Free MCP server for doc2md-lite (stdio).
|
|
2
|
+
|
|
3
|
+
Run: uvx --from git+https://github.com/tidybytetools/doc2md-lite doc2md-lite-mcp
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
from mcp.server.fastmcp import FastMCP
|
|
7
|
+
import doc2md_lite as lite
|
|
8
|
+
|
|
9
|
+
mcp = FastMCP("doc2md-lite")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@mcp.tool()
|
|
13
|
+
def convert_to_markdown(path: str, tables: str = "lines", output_path: str = "") -> str:
|
|
14
|
+
"""Turn a local text PDF or saved HTML file into clean Markdown.
|
|
15
|
+
|
|
16
|
+
path: full path to a .pdf or .html file on this computer.
|
|
17
|
+
tables: PDF tables. "lines" (default) finds tables with border lines, "text" also tries
|
|
18
|
+
tables lined up by spaces, "off" gives plain text.
|
|
19
|
+
output_path: optional. If set, the Markdown is saved there and that path is returned.
|
|
20
|
+
If empty, the Markdown text is returned.
|
|
21
|
+
|
|
22
|
+
Tables come back as Markdown tables. Scanned (image-only) PDF pages have no text and are skipped.
|
|
23
|
+
"""
|
|
24
|
+
path = os.path.expanduser(path)
|
|
25
|
+
if not os.path.isfile(path):
|
|
26
|
+
raise FileNotFoundError(f"file not found: {path}")
|
|
27
|
+
if tables not in ("lines", "text", "off"):
|
|
28
|
+
raise ValueError("tables must be lines, text or off")
|
|
29
|
+
md, _info = lite.convert(path, tables)
|
|
30
|
+
if output_path:
|
|
31
|
+
output_path = os.path.expanduser(output_path)
|
|
32
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
33
|
+
f.write(md)
|
|
34
|
+
return output_path
|
|
35
|
+
return md
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main():
|
|
39
|
+
mcp.run()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
main()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# doc2md-lite
|
|
2
|
+
|
|
3
|
+
> Free, MIT-licensed tool that turns a local text PDF or saved HTML file into clean Markdown. Tables become Markdown tables. Runs offline. Has a CLI, a Python API and a stdio MCP server.
|
|
4
|
+
|
|
5
|
+
- Install: `pip install doc2md-lite` (or from GitHub: `pip install git+https://github.com/tidybytetools/doc2md-lite`)
|
|
6
|
+
- CLI: `doc2md-lite file.pdf -o out.md [--tables lines|text|off] [--json]`. `-o -` prints the Markdown. Exit 0 ok, 1 error.
|
|
7
|
+
- Python: `from doc2md_lite import convert; md, info = convert("file.pdf")` where info is {"type", "tables", "headings", "scanned_pages"}.
|
|
8
|
+
- MCP: `uvx --from doc2md-lite doc2md-lite-mcp` exposes `convert_to_markdown(path, tables="lines", output_path="") -> str` (Markdown text, or the saved path if output_path is set).
|
|
9
|
+
|
|
10
|
+
## Use when
|
|
11
|
+
- You need a PDF with a text layer, or an HTML file, as Markdown for an LLM, a wiki or notes.
|
|
12
|
+
|
|
13
|
+
## Don't use when
|
|
14
|
+
- The PDF is scanned (pages are skipped), the file is Word (.docx), or you need RAG chunks with page and heading info, whole folders, or page ranges. The paid version does those: https://tidybyte.gumroad.com/l/frlkv
|
|
15
|
+
- You need to download web pages. This only reads files already on disk.
|
|
16
|
+
|
|
17
|
+
## Links
|
|
18
|
+
- [README](https://github.com/tidybytetools/doc2md-lite/blob/main/README.md)
|
|
19
|
+
- [Paid version](https://tidybyte.gumroad.com/l/frlkv)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "doc2md-lite"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Turn a text PDF or HTML file into clean Markdown, with tables kept as Markdown tables. Runs locally. CLI and MCP server."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{name = "Tidy Byte Tools"}]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"pdfplumber>=0.10",
|
|
16
|
+
"beautifulsoup4>=4.12",
|
|
17
|
+
"mcp>=1.2,<2",
|
|
18
|
+
]
|
|
19
|
+
keywords = ["pdf", "html", "markdown", "pdf-to-markdown", "html-to-markdown", "llm", "rag", "cli", "mcp", "mcp-server"]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 4 - Beta",
|
|
22
|
+
"Environment :: Console",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"Intended Audience :: End Users/Desktop",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
28
|
+
"Programming Language :: Python :: 3.10",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Topic :: Text Processing :: Markup :: Markdown",
|
|
33
|
+
"Topic :: Utilities",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://tidybyte.gumroad.com/l/frlkv"
|
|
38
|
+
Source = "https://github.com/tidybytetools/doc2md-lite"
|
|
39
|
+
Issues = "https://github.com/tidybytetools/doc2md-lite/issues"
|
|
40
|
+
Changelog = "https://github.com/tidybytetools/doc2md-lite/blob/main/CHANGELOG.md"
|
|
41
|
+
|
|
42
|
+
[project.scripts]
|
|
43
|
+
doc2md-lite = "doc2md_lite:main"
|
|
44
|
+
doc2md-lite-mcp = "doc2md_lite_mcp:main"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools]
|
|
47
|
+
py-modules = ["doc2md_lite", "doc2md_lite_mcp"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.tidybytetools/doc2md-lite",
|
|
4
|
+
"title": "doc2md-lite",
|
|
5
|
+
"description": "Turn local text PDFs and HTML files into clean Markdown, tables kept. Runs on your computer.",
|
|
6
|
+
"repository": {
|
|
7
|
+
"url": "https://github.com/tidybytetools/doc2md-lite",
|
|
8
|
+
"source": "github"
|
|
9
|
+
},
|
|
10
|
+
"websiteUrl": "https://github.com/tidybytetools/doc2md-lite",
|
|
11
|
+
"version": "1.0.0",
|
|
12
|
+
"packages": [
|
|
13
|
+
{
|
|
14
|
+
"registryType": "pypi",
|
|
15
|
+
"registryBaseUrl": "https://pypi.org",
|
|
16
|
+
"identifier": "doc2md-lite",
|
|
17
|
+
"version": "1.0.0",
|
|
18
|
+
"runtimeHint": "uvx",
|
|
19
|
+
"transport": {
|
|
20
|
+
"type": "stdio"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<!doctype html><html><head><title>Help Center: Returns</title><style>body{color:red}</style>
|
|
2
|
+
<script>console.log('x')</script></head><body>
|
|
3
|
+
<nav><a href="/">Home</a> | <a href="/shop">Shop</a></nav>
|
|
4
|
+
<main><h1>How returns work</h1>
|
|
5
|
+
<p>You can send back most items within <strong>30 days</strong>. See the <a href="https://example.com/policy">full policy</a>.</p>
|
|
6
|
+
<h2>Steps</h2><ol><li>Open your order.</li><li>Pick <em>Return item</em>.<ul><li>Print the label.</li></ul></li><li>Drop it off.</li></ol>
|
|
7
|
+
<h2>Refund times</h2>
|
|
8
|
+
<table><tr><th>Paid with</th><th>Refund time</th></tr><tr><td>Card</td><td>3-5 days</td></tr><tr><td>Gift card</td><td>1 day</td></tr></table>
|
|
9
|
+
<h3>Code for developers</h3><pre><code class="language-bash">curl https://example.com/api/returns</code></pre>
|
|
10
|
+
</main><footer>Copyright Example Shop</footer></body></html>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
%PDF-1.3
|
|
2
|
+
%���� ReportLab Generated PDF document (opensource)
|
|
3
|
+
1 0 obj
|
|
4
|
+
<<
|
|
5
|
+
/F1 2 0 R /F2 3 0 R
|
|
6
|
+
>>
|
|
7
|
+
endobj
|
|
8
|
+
2 0 obj
|
|
9
|
+
<<
|
|
10
|
+
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
|
11
|
+
>>
|
|
12
|
+
endobj
|
|
13
|
+
3 0 obj
|
|
14
|
+
<<
|
|
15
|
+
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
|
16
|
+
>>
|
|
17
|
+
endobj
|
|
18
|
+
4 0 obj
|
|
19
|
+
<<
|
|
20
|
+
/Contents 9 0 R /MediaBox [ 0 0 612 792 ] /Parent 8 0 R /Resources <<
|
|
21
|
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
|
22
|
+
>> /Rotate 0 /Trans <<
|
|
23
|
+
|
|
24
|
+
>>
|
|
25
|
+
/Type /Page
|
|
26
|
+
>>
|
|
27
|
+
endobj
|
|
28
|
+
5 0 obj
|
|
29
|
+
<<
|
|
30
|
+
/Contents 10 0 R /MediaBox [ 0 0 612 792 ] /Parent 8 0 R /Resources <<
|
|
31
|
+
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
|
32
|
+
>> /Rotate 0 /Trans <<
|
|
33
|
+
|
|
34
|
+
>>
|
|
35
|
+
/Type /Page
|
|
36
|
+
>>
|
|
37
|
+
endobj
|
|
38
|
+
6 0 obj
|
|
39
|
+
<<
|
|
40
|
+
/PageMode /UseNone /Pages 8 0 R /Type /Catalog
|
|
41
|
+
>>
|
|
42
|
+
endobj
|
|
43
|
+
7 0 obj
|
|
44
|
+
<<
|
|
45
|
+
/Author (anonymous) /CreationDate (D:20261104020609-06'00') /Creator (anonymous) /Keywords () /ModDate (D:20261104020609-06'00') /Producer (ReportLab PDF Library - \(opensource\))
|
|
46
|
+
/Subject (unspecified) /Title (untitled) /Trapped /False
|
|
47
|
+
>>
|
|
48
|
+
endobj
|
|
49
|
+
8 0 obj
|
|
50
|
+
<<
|
|
51
|
+
/Count 2 /Kids [ 4 0 R 5 0 R ] /Type /Pages
|
|
52
|
+
>>
|
|
53
|
+
endobj
|
|
54
|
+
9 0 obj
|
|
55
|
+
<<
|
|
56
|
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 624
|
|
57
|
+
>>
|
|
58
|
+
stream
|
|
59
|
+
Gat=i9lJK7!/Bt>(%7h$5ZNW2mjj2#+Z?,R&f30<l6_'/Y%!a!ruTp3Z?.OS=cIa3m,tdK=DnImr?M$NBRgURhVSqWML5dEH6Mj_pls[gE=-I$BfmC`B1,(KQurkpXS,rUQ<5T`EBNa(kgWht5EW]"=ZV2fN522i=`JIkI62OIS2D]E?Mre%nDCl?6<\Qgif8.Mjl&s*FW-KX\:U_?>2H"gCpsO_2c.1)-EK4+W"mn(f5E0s^"@4]Ts$Bp%I;0JooSK)`R[,Rkr[9[<hbqYD&f!%mj,?9"(t&VNgI.e^$"&_,dQ+fp#LWAm7&b`R6qt?TF%O('7D[!:`oP]P#`h,?Umc^](@!30^"VC&PTJZfj4u15n8\q3?T,K,NZIn.)!cOi@UsRh132jGIO$/E8hcL:LHM%(7_>C_@q&(VG$4O1CpacLNq<!%bEhQE34m-SBWq$5cobQ\d5TQ(<n^iNZ,b8r1-J<\);kG(.Kp5jr.L'<l7I6`+0`jgkPhXR`HS.k+4f+;WW9$\t2TDKZUHT+!aGI\^i,^%;\(..^n0%mPaHGN;fpk?VZIVC!(;kBUR9_Yl.nl>AJiWm6.qn6V9nI.3B!9XCN6NT.btc:RS`g"6Fg;mJ~>endstream
|
|
60
|
+
endobj
|
|
61
|
+
10 0 obj
|
|
62
|
+
<<
|
|
63
|
+
/Filter [ /ASCII85Decode /FlateDecode ] /Length 264
|
|
64
|
+
>>
|
|
65
|
+
stream
|
|
66
|
+
Gat%Z_+qm%&4H!cMK]2V$k=TN'dPc4?LSK8mA;=T]I7r'qY)84!Y2nCQIabm]pZ.\&'pQPlJU?8pM-rJY/*lX'_fV*RhWQQ4r/SGVqR?NXC=(c*ENWnP-/dH\=6iKn?dmt0\'=;T)[1+5"Hmc];>-+h[<(kFSN0A1:Vt"nusILOk^9Om;n278sD3LB=T>QGWDKQ**MMiAd/KD+.CAge[;ALYq`?/Qs7I,g6M$QKHP`g[_\S>'3'146a(K>rWP(Q#!+bRk5~>endstream
|
|
67
|
+
endobj
|
|
68
|
+
xref
|
|
69
|
+
0 11
|
|
70
|
+
0000000000 65535 f
|
|
71
|
+
0000000061 00000 n
|
|
72
|
+
0000000102 00000 n
|
|
73
|
+
0000000209 00000 n
|
|
74
|
+
0000000321 00000 n
|
|
75
|
+
0000000514 00000 n
|
|
76
|
+
0000000708 00000 n
|
|
77
|
+
0000000776 00000 n
|
|
78
|
+
0000001037 00000 n
|
|
79
|
+
0000001102 00000 n
|
|
80
|
+
0000001816 00000 n
|
|
81
|
+
trailer
|
|
82
|
+
<<
|
|
83
|
+
/ID
|
|
84
|
+
[<28c9aa324bdcb29741a9522844ac2db6><28c9aa324bdcb29741a9522844ac2db6>]
|
|
85
|
+
% ReportLab generated PDF document -- digest (opensource)
|
|
86
|
+
|
|
87
|
+
/Info 7 0 R
|
|
88
|
+
/Root 6 0 R
|
|
89
|
+
/Size 11
|
|
90
|
+
>>
|
|
91
|
+
startxref
|
|
92
|
+
2171
|
|
93
|
+
%%EOF
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import json, os, subprocess, sys, tempfile, unittest
|
|
2
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
3
|
+
ROOT = os.path.join(HERE, "..")
|
|
4
|
+
sys.path.insert(0, ROOT)
|
|
5
|
+
import doc2md_lite as lite
|
|
6
|
+
FX = os.path.join(HERE, "fixtures")
|
|
7
|
+
CLI = os.path.join(ROOT, "doc2md_lite.py")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class T(unittest.TestCase):
|
|
11
|
+
def cli(self, *args):
|
|
12
|
+
return subprocess.run([sys.executable, CLI, *args], capture_output=True, text=True)
|
|
13
|
+
|
|
14
|
+
def test_pdf(self):
|
|
15
|
+
md, info = lite.convert(os.path.join(FX, "store-report.pdf"))
|
|
16
|
+
self.assertTrue(md.startswith("# Spring Store Report"))
|
|
17
|
+
self.assertIn("| West | 15 | $61,300 |", md)
|
|
18
|
+
self.assertEqual(md.count("$61,300"), 1)
|
|
19
|
+
self.assertIn("- Check again in June.", md)
|
|
20
|
+
self.assertEqual(info["tables"], 1)
|
|
21
|
+
|
|
22
|
+
def test_html(self):
|
|
23
|
+
md, _ = lite.convert(os.path.join(FX, "returns-help.html"))
|
|
24
|
+
self.assertIn("# How returns work", md)
|
|
25
|
+
self.assertIn("| Gift card | 1 day |", md)
|
|
26
|
+
self.assertNotIn("console.log", md)
|
|
27
|
+
self.assertNotIn("Copyright", md)
|
|
28
|
+
|
|
29
|
+
def test_docx_refused(self):
|
|
30
|
+
with self.assertRaises(ValueError):
|
|
31
|
+
lite.convert("x.docx")
|
|
32
|
+
|
|
33
|
+
def test_cli_writes_file_and_json(self):
|
|
34
|
+
out = os.path.join(tempfile.mkdtemp(), "r.md")
|
|
35
|
+
r = self.cli(os.path.join(FX, "store-report.pdf"), "-o", out, "--json")
|
|
36
|
+
self.assertEqual(r.returncode, 0)
|
|
37
|
+
s = json.loads(r.stdout)
|
|
38
|
+
self.assertTrue(s["ok"]); self.assertEqual(s["tables"], 1)
|
|
39
|
+
self.assertIn("## Sales by region", open(out, encoding="utf-8").read())
|
|
40
|
+
|
|
41
|
+
def test_cli_stdout_and_no_tip_when_piped(self):
|
|
42
|
+
r = self.cli(os.path.join(FX, "returns-help.html"), "-o", "-")
|
|
43
|
+
self.assertTrue(r.stdout.startswith("# How returns work"))
|
|
44
|
+
self.assertNotIn("gumroad", r.stdout)
|
|
45
|
+
|
|
46
|
+
def test_cli_error(self):
|
|
47
|
+
r = self.cli("missing.pdf", "--json")
|
|
48
|
+
self.assertEqual(r.returncode, 1)
|
|
49
|
+
self.assertFalse(json.loads(r.stdout)["ok"])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
unittest.main()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Starts the MCP server over stdio, lists tools, and calls convert_to_markdown."""
|
|
2
|
+
import asyncio, os, sys, unittest
|
|
3
|
+
HERE = os.path.dirname(__file__)
|
|
4
|
+
ROOT = os.path.join(HERE, "..")
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from mcp import ClientSession, StdioServerParameters
|
|
8
|
+
from mcp.client.stdio import stdio_client
|
|
9
|
+
except ImportError:
|
|
10
|
+
ClientSession = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def _run():
|
|
14
|
+
params = StdioServerParameters(command=sys.executable, args=[os.path.join(ROOT, "doc2md_lite_mcp.py")], cwd=ROOT)
|
|
15
|
+
async with stdio_client(params) as (r, w):
|
|
16
|
+
async with ClientSession(r, w) as s:
|
|
17
|
+
await s.initialize()
|
|
18
|
+
tools = [t.name for t in (await s.list_tools()).tools]
|
|
19
|
+
res = await s.call_tool("convert_to_markdown", {"path": os.path.abspath(os.path.join(HERE, "fixtures", "store-report.pdf"))})
|
|
20
|
+
bad = await s.call_tool("convert_to_markdown", {"path": "/nope/missing.pdf"})
|
|
21
|
+
return tools, res, bad
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@unittest.skipIf(ClientSession is None, "mcp not installed")
|
|
25
|
+
class MCP(unittest.TestCase):
|
|
26
|
+
def test_lists_and_calls_tool(self):
|
|
27
|
+
tools, res, bad = asyncio.run(_run())
|
|
28
|
+
self.assertEqual(tools, ["convert_to_markdown"])
|
|
29
|
+
self.assertFalse(res.isError)
|
|
30
|
+
text = " ".join(c.text for c in res.content if getattr(c, "type", "") == "text")
|
|
31
|
+
self.assertIn("# Spring Store Report", text)
|
|
32
|
+
self.assertIn("| North | 12 | $48,200 |", text)
|
|
33
|
+
self.assertTrue(bad.isError)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
unittest.main()
|