pyhtml2text 0.2.0__tar.gz → 0.3.1__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.
- pyhtml2text-0.3.1/CHANGELOG.md +42 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/Cargo.lock +27 -2
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/Cargo.toml +8 -4
- pyhtml2text-0.3.1/PKG-INFO +141 -0
- pyhtml2text-0.3.1/README.md +124 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/pyproject.toml +1 -1
- pyhtml2text-0.3.1/python/pyhtml2text/__init__.py +3 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/python/pyhtml2text/__init__.pyi +1 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/src/lib.rs +59 -28
- pyhtml2text-0.3.1/src/markdown.rs +2442 -0
- pyhtml2text-0.3.1/tests/markdown_check.py +464 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/tests/test_basic.py +17 -9
- pyhtml2text-0.3.1/tests/test_markdown.py +209 -0
- pyhtml2text-0.3.1/vendor/README.md +48 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/tree_builder/mod.rs +95 -118
- pyhtml2text-0.3.1/vendor/html5ever/src/tree_builder/open_elems.rs +501 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/tree_builder/rules.rs +124 -101
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/tree_builder/tag_sets.rs +10 -0
- pyhtml2text-0.2.0/CHANGELOG.md +0 -21
- pyhtml2text-0.2.0/PKG-INFO +0 -79
- pyhtml2text-0.2.0/README.md +0 -62
- pyhtml2text-0.2.0/python/pyhtml2text/__init__.py +0 -3
- pyhtml2text-0.2.0/vendor/README.md +0 -13
- pyhtml2text-0.2.0/vendor/html5ever/src/tree_builder/open_elems.rs +0 -134
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/.gitignore +0 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/LICENSE +0 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/python/pyhtml2text/py.typed +0 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/vendor/html5ever/Cargo.toml +0 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/vendor/html5ever/LICENSE-APACHE +0 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/vendor/html5ever/LICENSE-MIT +0 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/vendor/html5ever/README.md +0 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/driver.rs +0 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/encoding.rs +0 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/lib.rs +0 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/macros.rs +0 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/serialize/mod.rs +0 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/tokenizer/char_ref/mod.rs +0 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/tokenizer/interface.rs +0 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/tokenizer/mod.rs +0 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/tokenizer/states.rs +0 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/tree_builder/data.rs +0 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/tree_builder/types.rs +0 -0
- {pyhtml2text-0.2.0 → pyhtml2text-0.3.1}/vendor/html5ever/src/util/str.rs +0 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.1 (2026-10-19)
|
|
4
|
+
|
|
5
|
+
- The vendored html5ever now carries
|
|
6
|
+
[servo/html5ever#800](https://github.com/servo/html5ever/pull/800) by Josef
|
|
7
|
+
Guenther in place of our narrower patch. Every "in scope" check and the
|
|
8
|
+
other searches of the stack of open elements are now O(1), so unmatched
|
|
9
|
+
end tags and end tags inside SVG or MathML no longer make deeply nested
|
|
10
|
+
HTML quadratic: 32,000 nested `<span>` followed by as many unmatched
|
|
11
|
+
`</em>` went from 3.4 s to 0.02 s. Output is unchanged.
|
|
12
|
+
- About 0.4 µs more per call, and 0–2% on real pages.
|
|
13
|
+
|
|
14
|
+
## 0.3.0 (2026-10-15)
|
|
15
|
+
|
|
16
|
+
- New `html2markdown(html, *, base_url=None)`: HTML to CommonMark with
|
|
17
|
+
GitHub's tables and strikethrough. Links and images keep their URLs (made
|
|
18
|
+
absolute with `base_url`), tables become pipe tables, code blocks are
|
|
19
|
+
fenced with their language, and characters are escaped only where Markdown
|
|
20
|
+
would misread them. 3–13× faster than markdownify, html2text and
|
|
21
|
+
html-to-markdown on real pages.
|
|
22
|
+
- `html2text()` output is unchanged.
|
|
23
|
+
|
|
24
|
+
## 0.2.0 (2026-10-15)
|
|
25
|
+
|
|
26
|
+
- 1.5–1.7× faster than 0.1.0 on real web pages, and 2× faster on short
|
|
27
|
+
snippets. Output is unchanged.
|
|
28
|
+
- Wheels for Linux x86_64/aarch64, macOS and Windows x64 are built with
|
|
29
|
+
profile-guided optimisation.
|
|
30
|
+
- Uses mimalloc as the allocator (except on glibc armv7, ppc64le and s390x).
|
|
31
|
+
- Style rules are shared rather than rebuilt and copied for every call.
|
|
32
|
+
- Deeply nested HTML no longer takes quadratic time to parse: 40,000 nested
|
|
33
|
+
`<ul>` lists went from 6s to 0.14s (patched html5ever, see `vendor/`).
|
|
34
|
+
- `html` can be UTF-8 `bytes` as well as `str`.
|
|
35
|
+
- An internal error now raises `ValueError` rather than `PanicException`
|
|
36
|
+
(which `except Exception` doesn't catch).
|
|
37
|
+
- Supports free-threaded Python without re-enabling the GIL.
|
|
38
|
+
- Requires Python 3.10 or later (3.9 is end-of-life).
|
|
39
|
+
|
|
40
|
+
## 0.1.0 (2026-10-15)
|
|
41
|
+
|
|
42
|
+
- First release: `html2text()` with `base_url` support.
|
|
@@ -51,7 +51,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|
|
51
51
|
[[package]]
|
|
52
52
|
name = "html2text"
|
|
53
53
|
version = "0.17.1"
|
|
54
|
-
source = "git+https://github.com/jaredlynskey/rust-html2text?rev=
|
|
54
|
+
source = "git+https://github.com/jaredlynskey/rust-html2text?rev=b42930f8e6688ea121daf392e27a7d9812e6192d#b42930f8e6688ea121daf392e27a7d9812e6192d"
|
|
55
55
|
dependencies = [
|
|
56
56
|
"html5ever",
|
|
57
57
|
"tendril",
|
|
@@ -117,6 +117,12 @@ dependencies = [
|
|
|
117
117
|
"web_atoms",
|
|
118
118
|
]
|
|
119
119
|
|
|
120
|
+
[[package]]
|
|
121
|
+
name = "memchr"
|
|
122
|
+
version = "2.8.3"
|
|
123
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
124
|
+
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
|
125
|
+
|
|
120
126
|
[[package]]
|
|
121
127
|
name = "memoffset"
|
|
122
128
|
version = "0.9.1"
|
|
@@ -230,12 +236,25 @@ dependencies = [
|
|
|
230
236
|
"unicode-ident",
|
|
231
237
|
]
|
|
232
238
|
|
|
239
|
+
[[package]]
|
|
240
|
+
name = "pulldown-cmark"
|
|
241
|
+
version = "0.13.4"
|
|
242
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
243
|
+
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
|
244
|
+
dependencies = [
|
|
245
|
+
"bitflags",
|
|
246
|
+
"memchr",
|
|
247
|
+
"unicase",
|
|
248
|
+
]
|
|
249
|
+
|
|
233
250
|
[[package]]
|
|
234
251
|
name = "pyhtml2text"
|
|
235
|
-
version = "0.
|
|
252
|
+
version = "0.3.1"
|
|
236
253
|
dependencies = [
|
|
237
254
|
"html2text",
|
|
255
|
+
"html5ever",
|
|
238
256
|
"mimalloc",
|
|
257
|
+
"pulldown-cmark",
|
|
239
258
|
"pyo3",
|
|
240
259
|
]
|
|
241
260
|
|
|
@@ -458,6 +477,12 @@ dependencies = [
|
|
|
458
477
|
"syn 3.0.9",
|
|
459
478
|
]
|
|
460
479
|
|
|
480
|
+
[[package]]
|
|
481
|
+
name = "unicase"
|
|
482
|
+
version = "2.10.0"
|
|
483
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
484
|
+
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
|
|
485
|
+
|
|
461
486
|
[[package]]
|
|
462
487
|
name = "unicode-ident"
|
|
463
488
|
version = "1.0.27"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "pyhtml2text"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
license = "MIT"
|
|
6
6
|
publish = false
|
|
@@ -12,9 +12,13 @@ crate-type = ["cdylib"]
|
|
|
12
12
|
|
|
13
13
|
[dependencies]
|
|
14
14
|
pyo3 = { version = "0.26", features = ["extension-module", "abi3-py310"] }
|
|
15
|
-
# Fork of jugglerchris/rust-html2text with Config::base_url
|
|
16
|
-
#
|
|
17
|
-
html2text = { git = "https://github.com/jaredlynskey/rust-html2text", rev = "
|
|
15
|
+
# Fork of jugglerchris/rust-html2text with Config::base_url, some performance
|
|
16
|
+
# changes and the DOM exports the Markdown output uses (branch pyhtml2text-perf).
|
|
17
|
+
html2text = { git = "https://github.com/jaredlynskey/rust-html2text", rev = "b42930f8e6688ea121daf392e27a7d9812e6192d" }
|
|
18
|
+
# The parser html2text uses; patched below. Used directly to read element attributes.
|
|
19
|
+
html5ever = "0.39"
|
|
20
|
+
# Reads the Markdown back to check that emphasis parses as intended.
|
|
21
|
+
pulldown-cmark = { version = "0.13", default-features = false }
|
|
18
22
|
|
|
19
23
|
# Much faster than the system allocators for html2text's many small allocations.
|
|
20
24
|
# Not on glibc armv7/ppc64le/s390x: their manylinux2014 cross compilers are too
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyhtml2text
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Classifier: Programming Language :: Rust
|
|
5
|
+
Classifier: Programming Language :: Python :: 3
|
|
6
|
+
Classifier: Topic :: Text Processing :: Markup :: HTML
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Summary: Fast HTML to plain text, with relative link resolution (Python bindings to rust-html2text)
|
|
9
|
+
Keywords: html,text,markdown,html2text
|
|
10
|
+
Author-email: Jared Lynskey <jared@lynskey.co.nz>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
14
|
+
Project-URL: Source, https://github.com/jaredlynskey/pyhtml2text
|
|
15
|
+
Project-URL: Upstream, https://github.com/jugglerchris/rust-html2text
|
|
16
|
+
|
|
17
|
+
# pyhtml2text
|
|
18
|
+
|
|
19
|
+
Fast HTML to plain text and to Markdown for Python, built on
|
|
20
|
+
[rust-html2text](https://github.com/jugglerchris/rust-html2text).
|
|
21
|
+
|
|
22
|
+
Relative links can be resolved against the page's URL, so the output's links
|
|
23
|
+
work on their own.
|
|
24
|
+
|
|
25
|
+
## Markdown
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from pyhtml2text import html2markdown
|
|
29
|
+
|
|
30
|
+
html = """<h2>Before you start</h2>
|
|
31
|
+
<p>Read the <a href="setup.html">setup guide</a> and <b>back up</b> your data.</p>
|
|
32
|
+
<img src="/img/diagram.png" alt="How it fits together">
|
|
33
|
+
<table><tr><th>Plan</th><th>Price</th></tr><tr><td>Basic</td><td>Free</td></tr></table>"""
|
|
34
|
+
|
|
35
|
+
print(html2markdown(html, base_url="https://example.com/docs/intro.html"))
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```markdown
|
|
39
|
+
## Before you start
|
|
40
|
+
|
|
41
|
+
Read the [setup guide](https://example.com/docs/setup.html) and **back up** your data.
|
|
42
|
+
|
|
43
|
+

|
|
44
|
+
|
|
45
|
+
| Plan | Price |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| Basic | Free |
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`html2markdown(html, *, base_url=None)` writes
|
|
51
|
+
[CommonMark](https://commonmark.org/) with GitHub's tables and
|
|
52
|
+
strikethrough, in the usual conventions:
|
|
53
|
+
|
|
54
|
+
- `#` headings, `-` bullets, `_emphasis_` and `**strong**`, `~~strikethrough~~`.
|
|
55
|
+
- Inline links and images, `[text](url)` and ``, made absolute when
|
|
56
|
+
`base_url` is given. A link whose only content is an icon uses its
|
|
57
|
+
`aria-label` or `title` as its text.
|
|
58
|
+
- Fenced code blocks, tagged with the language when the page names one
|
|
59
|
+
(`class="language-python"`).
|
|
60
|
+
- Pipe tables. Cells spanning rows or columns become empty cells, a table
|
|
61
|
+
without a header row gets an empty one, and tables used for page layout are
|
|
62
|
+
written as plain paragraphs.
|
|
63
|
+
- Characters are escaped only where Markdown would otherwise read them as
|
|
64
|
+
syntax, so `snake_case`, `5 * 3` and `$5` stay as they are.
|
|
65
|
+
- Lines aren't wrapped. Hidden content (`hidden`, `aria-hidden`,
|
|
66
|
+
`display: none`), scripts, styles and form controls are left out.
|
|
67
|
+
|
|
68
|
+
The output is checked by rendering it back with a CommonMark parser and
|
|
69
|
+
comparing every word, link and image with the original page, on real sites
|
|
70
|
+
and on tens of thousands of random documents (`tests/markdown_check.py`).
|
|
71
|
+
|
|
72
|
+
## Plain text
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from pyhtml2text import html2text
|
|
76
|
+
|
|
77
|
+
html = '<p>Please <a href="/login">sign in</a> first.</p>'
|
|
78
|
+
|
|
79
|
+
print(html2text(html, base_url="https://example.com/docs/page.html"))
|
|
80
|
+
# Please [sign in][1] first.
|
|
81
|
+
#
|
|
82
|
+
# [1]: https://example.com/login
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`html2text(html, width=80, *, ...)`
|
|
86
|
+
|
|
87
|
+
| option | default | |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `base_url` | `None` | resolve relative `href`s against this URL (RFC 3986). Absolute and `mailto:` etc. links are left alone |
|
|
90
|
+
| `decorate` | `True` | markdown-ish `*em*`, `**strong**`, `` `code` `` |
|
|
91
|
+
| `link_footnotes` | `True` | list link targets as `[1]: ...` footnotes |
|
|
92
|
+
| `table_borders` | `True` | draw table borders |
|
|
93
|
+
| `wrap_links` | `True` | allow long links to wrap |
|
|
94
|
+
| `max_wrap_width` | `None` | wrap paragraphs narrower than `width` |
|
|
95
|
+
| `raw` | `False` | flatten tables so cell text stays together |
|
|
96
|
+
|
|
97
|
+
Both functions take a `str` or UTF-8 `bytes` (e.g. `response.content`).
|
|
98
|
+
Requires Python 3.10 or later.
|
|
99
|
+
|
|
100
|
+
## Performance
|
|
101
|
+
|
|
102
|
+
Median time on an Apple Silicon Mac. To Markdown:
|
|
103
|
+
|
|
104
|
+
| | Wikipedia article (1 MB) | BBC News front page (370 KB) | Python docs page (245 KB) |
|
|
105
|
+
|---|---|---|---|
|
|
106
|
+
| [markdownify](https://pypi.org/project/markdownify/) | 184 ms | 28 ms | 97 ms |
|
|
107
|
+
| [html2text](https://pypi.org/project/html2text/) (pure Python) | 73 ms | 13 ms | 37 ms |
|
|
108
|
+
| [html-to-markdown](https://pypi.org/project/html-to-markdown/) | 44 ms | 5.6 ms | 15 ms |
|
|
109
|
+
| **pyhtml2text** | **14 ms** | **1.7 ms** | **4.5 ms** |
|
|
110
|
+
|
|
111
|
+
To plain text:
|
|
112
|
+
|
|
113
|
+
| | Wikipedia article (780 KB) | BBC News front page (370 KB) |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| [html2text](https://pypi.org/project/html2text/) (pure Python) | 71.5 ms | 14.9 ms |
|
|
116
|
+
| [html2text-rs](https://pypi.org/project/html2text-rs/) | 15.2 ms | 2.5 ms |
|
|
117
|
+
| **pyhtml2text** | **10.9 ms** | **1.6 ms** |
|
|
118
|
+
|
|
119
|
+
- The GIL is released while converting, so threads scale across cores. On
|
|
120
|
+
free-threaded Python (3.14t) the module runs without re-enabling the GIL.
|
|
121
|
+
- Wheels for Linux (x86_64, aarch64), macOS and Windows x64 are built with
|
|
122
|
+
profile-guided optimisation, which makes them about 25% faster. Other
|
|
123
|
+
platforms get the same code without it.
|
|
124
|
+
- Deeply nested HTML is parsed in linear time, so hostile input can't stall a
|
|
125
|
+
worker: 40,000 nested lists take 0.14 s, against 6 s with stock html5ever,
|
|
126
|
+
and 100,000 levels of nesting followed by unmatched end tags take under
|
|
127
|
+
0.1 s.
|
|
128
|
+
|
|
129
|
+
## Credits
|
|
130
|
+
|
|
131
|
+
The plain-text rendering is done by rust-html2text by Chris Emerson (MIT).
|
|
132
|
+
This package uses [a fork](https://github.com/jaredlynskey/rust-html2text/tree/pyhtml2text-perf)
|
|
133
|
+
that adds `base_url` and some performance changes, and a copy of
|
|
134
|
+
[html5ever](https://github.com/servo/html5ever) (MIT/Apache-2.0) with
|
|
135
|
+
[servo/html5ever#800](https://github.com/servo/html5ever/pull/800) by Josef
|
|
136
|
+
Guenther applied, which makes parsing deeply nested HTML linear (see
|
|
137
|
+
`vendor/`). Markdown output is checked with
|
|
138
|
+
[pulldown-cmark](https://github.com/raphlinus/pulldown-cmark) (MIT). It
|
|
139
|
+
isn't affiliated with or endorsed by any of these projects, so please report
|
|
140
|
+
issues here rather than upstream.
|
|
141
|
+
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# pyhtml2text
|
|
2
|
+
|
|
3
|
+
Fast HTML to plain text and to Markdown for Python, built on
|
|
4
|
+
[rust-html2text](https://github.com/jugglerchris/rust-html2text).
|
|
5
|
+
|
|
6
|
+
Relative links can be resolved against the page's URL, so the output's links
|
|
7
|
+
work on their own.
|
|
8
|
+
|
|
9
|
+
## Markdown
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from pyhtml2text import html2markdown
|
|
13
|
+
|
|
14
|
+
html = """<h2>Before you start</h2>
|
|
15
|
+
<p>Read the <a href="setup.html">setup guide</a> and <b>back up</b> your data.</p>
|
|
16
|
+
<img src="/img/diagram.png" alt="How it fits together">
|
|
17
|
+
<table><tr><th>Plan</th><th>Price</th></tr><tr><td>Basic</td><td>Free</td></tr></table>"""
|
|
18
|
+
|
|
19
|
+
print(html2markdown(html, base_url="https://example.com/docs/intro.html"))
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```markdown
|
|
23
|
+
## Before you start
|
|
24
|
+
|
|
25
|
+
Read the [setup guide](https://example.com/docs/setup.html) and **back up** your data.
|
|
26
|
+
|
|
27
|
+

|
|
28
|
+
|
|
29
|
+
| Plan | Price |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| Basic | Free |
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`html2markdown(html, *, base_url=None)` writes
|
|
35
|
+
[CommonMark](https://commonmark.org/) with GitHub's tables and
|
|
36
|
+
strikethrough, in the usual conventions:
|
|
37
|
+
|
|
38
|
+
- `#` headings, `-` bullets, `_emphasis_` and `**strong**`, `~~strikethrough~~`.
|
|
39
|
+
- Inline links and images, `[text](url)` and ``, made absolute when
|
|
40
|
+
`base_url` is given. A link whose only content is an icon uses its
|
|
41
|
+
`aria-label` or `title` as its text.
|
|
42
|
+
- Fenced code blocks, tagged with the language when the page names one
|
|
43
|
+
(`class="language-python"`).
|
|
44
|
+
- Pipe tables. Cells spanning rows or columns become empty cells, a table
|
|
45
|
+
without a header row gets an empty one, and tables used for page layout are
|
|
46
|
+
written as plain paragraphs.
|
|
47
|
+
- Characters are escaped only where Markdown would otherwise read them as
|
|
48
|
+
syntax, so `snake_case`, `5 * 3` and `$5` stay as they are.
|
|
49
|
+
- Lines aren't wrapped. Hidden content (`hidden`, `aria-hidden`,
|
|
50
|
+
`display: none`), scripts, styles and form controls are left out.
|
|
51
|
+
|
|
52
|
+
The output is checked by rendering it back with a CommonMark parser and
|
|
53
|
+
comparing every word, link and image with the original page, on real sites
|
|
54
|
+
and on tens of thousands of random documents (`tests/markdown_check.py`).
|
|
55
|
+
|
|
56
|
+
## Plain text
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from pyhtml2text import html2text
|
|
60
|
+
|
|
61
|
+
html = '<p>Please <a href="/login">sign in</a> first.</p>'
|
|
62
|
+
|
|
63
|
+
print(html2text(html, base_url="https://example.com/docs/page.html"))
|
|
64
|
+
# Please [sign in][1] first.
|
|
65
|
+
#
|
|
66
|
+
# [1]: https://example.com/login
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`html2text(html, width=80, *, ...)`
|
|
70
|
+
|
|
71
|
+
| option | default | |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `base_url` | `None` | resolve relative `href`s against this URL (RFC 3986). Absolute and `mailto:` etc. links are left alone |
|
|
74
|
+
| `decorate` | `True` | markdown-ish `*em*`, `**strong**`, `` `code` `` |
|
|
75
|
+
| `link_footnotes` | `True` | list link targets as `[1]: ...` footnotes |
|
|
76
|
+
| `table_borders` | `True` | draw table borders |
|
|
77
|
+
| `wrap_links` | `True` | allow long links to wrap |
|
|
78
|
+
| `max_wrap_width` | `None` | wrap paragraphs narrower than `width` |
|
|
79
|
+
| `raw` | `False` | flatten tables so cell text stays together |
|
|
80
|
+
|
|
81
|
+
Both functions take a `str` or UTF-8 `bytes` (e.g. `response.content`).
|
|
82
|
+
Requires Python 3.10 or later.
|
|
83
|
+
|
|
84
|
+
## Performance
|
|
85
|
+
|
|
86
|
+
Median time on an Apple Silicon Mac. To Markdown:
|
|
87
|
+
|
|
88
|
+
| | Wikipedia article (1 MB) | BBC News front page (370 KB) | Python docs page (245 KB) |
|
|
89
|
+
|---|---|---|---|
|
|
90
|
+
| [markdownify](https://pypi.org/project/markdownify/) | 184 ms | 28 ms | 97 ms |
|
|
91
|
+
| [html2text](https://pypi.org/project/html2text/) (pure Python) | 73 ms | 13 ms | 37 ms |
|
|
92
|
+
| [html-to-markdown](https://pypi.org/project/html-to-markdown/) | 44 ms | 5.6 ms | 15 ms |
|
|
93
|
+
| **pyhtml2text** | **14 ms** | **1.7 ms** | **4.5 ms** |
|
|
94
|
+
|
|
95
|
+
To plain text:
|
|
96
|
+
|
|
97
|
+
| | Wikipedia article (780 KB) | BBC News front page (370 KB) |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| [html2text](https://pypi.org/project/html2text/) (pure Python) | 71.5 ms | 14.9 ms |
|
|
100
|
+
| [html2text-rs](https://pypi.org/project/html2text-rs/) | 15.2 ms | 2.5 ms |
|
|
101
|
+
| **pyhtml2text** | **10.9 ms** | **1.6 ms** |
|
|
102
|
+
|
|
103
|
+
- The GIL is released while converting, so threads scale across cores. On
|
|
104
|
+
free-threaded Python (3.14t) the module runs without re-enabling the GIL.
|
|
105
|
+
- Wheels for Linux (x86_64, aarch64), macOS and Windows x64 are built with
|
|
106
|
+
profile-guided optimisation, which makes them about 25% faster. Other
|
|
107
|
+
platforms get the same code without it.
|
|
108
|
+
- Deeply nested HTML is parsed in linear time, so hostile input can't stall a
|
|
109
|
+
worker: 40,000 nested lists take 0.14 s, against 6 s with stock html5ever,
|
|
110
|
+
and 100,000 levels of nesting followed by unmatched end tags take under
|
|
111
|
+
0.1 s.
|
|
112
|
+
|
|
113
|
+
## Credits
|
|
114
|
+
|
|
115
|
+
The plain-text rendering is done by rust-html2text by Chris Emerson (MIT).
|
|
116
|
+
This package uses [a fork](https://github.com/jaredlynskey/rust-html2text/tree/pyhtml2text-perf)
|
|
117
|
+
that adds `base_url` and some performance changes, and a copy of
|
|
118
|
+
[html5ever](https://github.com/servo/html5ever) (MIT/Apache-2.0) with
|
|
119
|
+
[servo/html5ever#800](https://github.com/servo/html5ever/pull/800) by Josef
|
|
120
|
+
Guenther applied, which makes parsing deeply nested HTML linear (see
|
|
121
|
+
`vendor/`). Markdown output is checked with
|
|
122
|
+
[pulldown-cmark](https://github.com/raphlinus/pulldown-cmark) (MIT). It
|
|
123
|
+
isn't affiliated with or endorsed by any of these projects, so please report
|
|
124
|
+
issues here rather than upstream.
|
|
@@ -5,6 +5,8 @@ use pyo3::exceptions::{PyTypeError, PyValueError};
|
|
|
5
5
|
use pyo3::prelude::*;
|
|
6
6
|
use pyo3::types::{PyBytes, PyString};
|
|
7
7
|
|
|
8
|
+
mod markdown;
|
|
9
|
+
|
|
8
10
|
#[cfg(not(all(
|
|
9
11
|
target_env = "gnu",
|
|
10
12
|
any(target_arch = "arm", target_arch = "powerpc64", target_arch = "s390x")
|
|
@@ -12,6 +14,43 @@ use pyo3::types::{PyBytes, PyString};
|
|
|
12
14
|
#[global_allocator]
|
|
13
15
|
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
|
14
16
|
|
|
17
|
+
/// Borrow the input without copying it; bytes are taken to be UTF-8.
|
|
18
|
+
fn input_bytes<'a>(html: &'a Bound<'_, PyAny>) -> PyResult<&'a [u8]> {
|
|
19
|
+
if let Ok(s) = html.downcast::<PyString>() {
|
|
20
|
+
Ok(s.to_str()?.as_bytes())
|
|
21
|
+
} else if let Ok(b) = html.downcast::<PyBytes>() {
|
|
22
|
+
Ok(b.as_bytes())
|
|
23
|
+
} else {
|
|
24
|
+
Err(PyTypeError::new_err(format!(
|
|
25
|
+
"html must be str or bytes, not {}",
|
|
26
|
+
html.get_type().name()?
|
|
27
|
+
)))
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Run a conversion. It is pure Rust, so other Python threads run meanwhile.
|
|
32
|
+
/// A panic would otherwise surface as PanicException, which `except
|
|
33
|
+
/// Exception` doesn't catch, so it is reported as a ValueError instead.
|
|
34
|
+
fn convert<F>(py: Python<'_>, f: F) -> PyResult<String>
|
|
35
|
+
where
|
|
36
|
+
F: FnOnce() -> Result<String, html2text::Error> + Send,
|
|
37
|
+
{
|
|
38
|
+
match py.detach(|| catch_unwind(AssertUnwindSafe(|| f().map_err(|e| e.to_string())))) {
|
|
39
|
+
Ok(Ok(text)) => Ok(text),
|
|
40
|
+
Ok(Err(e)) => Err(PyValueError::new_err(e)),
|
|
41
|
+
Err(panic) => {
|
|
42
|
+
let msg = panic
|
|
43
|
+
.downcast_ref::<&str>()
|
|
44
|
+
.copied()
|
|
45
|
+
.or_else(|| panic.downcast_ref::<String>().map(String::as_str))
|
|
46
|
+
.unwrap_or("unknown error");
|
|
47
|
+
Err(PyValueError::new_err(format!(
|
|
48
|
+
"html2text failed on this document: {msg}"
|
|
49
|
+
)))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
15
54
|
/// Render HTML as plain text.
|
|
16
55
|
#[pyfunction]
|
|
17
56
|
#[pyo3(name = "html2text", signature = (
|
|
@@ -39,17 +78,7 @@ fn render(
|
|
|
39
78
|
max_wrap_width: Option<usize>,
|
|
40
79
|
raw: bool,
|
|
41
80
|
) -> PyResult<String> {
|
|
42
|
-
|
|
43
|
-
let input: &[u8] = if let Ok(s) = html.downcast::<PyString>() {
|
|
44
|
-
s.to_str()?.as_bytes()
|
|
45
|
-
} else if let Ok(b) = html.downcast::<PyBytes>() {
|
|
46
|
-
b.as_bytes()
|
|
47
|
-
} else {
|
|
48
|
-
return Err(PyTypeError::new_err(format!(
|
|
49
|
-
"html must be str or bytes, not {}",
|
|
50
|
-
html.get_type().name()?
|
|
51
|
-
)));
|
|
52
|
-
};
|
|
81
|
+
let input = input_bytes(html)?;
|
|
53
82
|
|
|
54
83
|
let mut conf = if decorate {
|
|
55
84
|
config::plain()
|
|
@@ -73,27 +102,29 @@ fn render(
|
|
|
73
102
|
conf = conf.raw_mode(true);
|
|
74
103
|
}
|
|
75
104
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
105
|
+
convert(py, || conf.string_from_read(input, width))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// Convert HTML to Markdown.
|
|
109
|
+
///
|
|
110
|
+
/// The output is CommonMark with GitHub's pipe tables and strikethrough:
|
|
111
|
+
/// ATX headings, `-` bullets, fenced code blocks and inline links and images.
|
|
112
|
+
/// Lines are not wrapped. Relative links and image sources are resolved
|
|
113
|
+
/// against `base_url` when one is given.
|
|
114
|
+
#[pyfunction]
|
|
115
|
+
#[pyo3(name = "html2markdown", signature = (html, *, base_url = None))]
|
|
116
|
+
fn to_markdown(
|
|
117
|
+
py: Python<'_>,
|
|
118
|
+
html: &Bound<'_, PyAny>,
|
|
119
|
+
base_url: Option<String>,
|
|
120
|
+
) -> PyResult<String> {
|
|
121
|
+
let input = input_bytes(html)?;
|
|
122
|
+
convert(py, || markdown::from_read(input, base_url.as_deref()))
|
|
93
123
|
}
|
|
94
124
|
|
|
95
125
|
#[pymodule(gil_used = false)]
|
|
96
126
|
fn _pyhtml2text(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
97
127
|
m.add_function(wrap_pyfunction!(render, m)?)?;
|
|
128
|
+
m.add_function(wrap_pyfunction!(to_markdown, m)?)?;
|
|
98
129
|
Ok(())
|
|
99
130
|
}
|