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.
@@ -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,4 @@
1
+ include CHANGELOG.md
2
+ include llms.txt
3
+ include server.json
4
+ recursive-include tests *.py *.pdf *.html
@@ -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
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/tidybytetools/doc2md-lite/blob/main/LICENSE)
36
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/)
37
+ [![MCP server](https://img.shields.io/badge/MCP-stdio-purple.svg)](#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
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/tidybytetools/doc2md-lite/blob/main/LICENSE)
4
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/)
5
+ [![MCP server](https://img.shields.io/badge/MCP-stdio-purple.svg)](#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
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/tidybytetools/doc2md-lite/blob/main/LICENSE)
36
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/)
37
+ [![MCP server](https://img.shields.io/badge/MCP-stdio-purple.svg)](#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,3 @@
1
+ [console_scripts]
2
+ doc2md-lite = doc2md_lite:main
3
+ doc2md-lite-mcp = doc2md_lite_mcp:main
@@ -0,0 +1,3 @@
1
+ pdfplumber>=0.10
2
+ beautifulsoup4>=4.12
3
+ mcp<2,>=1.2
@@ -0,0 +1,2 @@
1
+ doc2md_lite
2
+ doc2md_lite_mcp
@@ -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"![{alt}]({c.get('src', '')})")
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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()