htomd 0.1.0__py3-none-any.whl

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.
htomd/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """Focused Markdown and metadata from decoded HTML, with no network access."""
2
+
3
+ from ._metadata import read_metadata, refine_metadata
4
+ from ._models import Diagnostics, Document, Metadata
5
+ from ._parser import parse
6
+ from ._render import render
7
+ from ._selection import select
8
+ from ._urls import document_base
9
+
10
+ __all__ = ["Diagnostics", "Document", "Metadata", "convert", "extract"]
11
+
12
+
13
+ def extract(html: str, *, url: str | None = None) -> Document:
14
+ """Extract relevant Markdown and explicit metadata from an HTML string.
15
+
16
+ ``url`` provides source context and resolves references; no fetching occurs.
17
+ Malformed HTML receives best-effort recovery. Result dataclasses are immutable.
18
+ """
19
+ if not isinstance(html, str):
20
+ raise TypeError("html must be a decoded str")
21
+ if url is not None and not isinstance(url, str):
22
+ raise TypeError("url must be a str or None")
23
+ root, parsing_notes = parse(html)
24
+ base = document_base(root, url)
25
+ metadata = read_metadata(root, url, base)
26
+ selected, diagnostics = select(root)
27
+ metadata = refine_metadata(metadata, selected)
28
+ markdown = render(selected, base)
29
+ diagnostics = Diagnostics(
30
+ diagnostics.strategy if markdown else "none", parsing_notes + diagnostics.notes
31
+ )
32
+ return Document(markdown, metadata, diagnostics)
33
+
34
+
35
+ def convert(html: str, *, url: str | None = None) -> str:
36
+ """Return the Markdown produced by :func:`extract`."""
37
+ return extract(html, url=url).markdown
htomd/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Support ``python -m htomd``."""
2
+
3
+ from ._cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
htomd/_cli.py ADDED
@@ -0,0 +1,92 @@
1
+ """Command-line conversion of HTML supplied on stdin."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ from dataclasses import asdict
8
+ from importlib.metadata import version
9
+
10
+ from . import convert, extract
11
+
12
+
13
+ def main(argv: list[str] | None = None) -> int:
14
+ parser = argparse.ArgumentParser(
15
+ prog="htomd",
16
+ description=(
17
+ "Extract Markdown and metadata from UTF-8 HTML on stdin. "
18
+ "Pipe input from cat or curl; output goes to stdout. No file arguments or fetching."
19
+ ),
20
+ epilog=(
21
+ "Examples:\n"
22
+ " cat page.html | htomd convert > page.md\n"
23
+ " cat page.html | htomd extract > page.json\n"
24
+ " curl -fsSL https://example.com | htomd convert --url https://example.com\n"
25
+ "\nUse 'htomd help COMMAND' for command-specific help."
26
+ ),
27
+ formatter_class=argparse.RawDescriptionHelpFormatter,
28
+ )
29
+ version_text = f"htomd {version('htomd')}"
30
+ parser.add_argument("--version", action="version", version=version_text)
31
+ commands = parser.add_subparsers(dest="command")
32
+ command_parsers: dict[str, argparse.ArgumentParser] = {}
33
+ for name, description in (
34
+ ("convert", "Write Markdown to stdout."),
35
+ ("extract", "Write Markdown, metadata, and diagnostics as JSON to stdout."),
36
+ ):
37
+ command = commands.add_parser(
38
+ name,
39
+ help=description,
40
+ description=f"Read UTF-8 HTML from stdin to EOF. {description}",
41
+ epilog=f"Example: cat page.html | htomd {name}",
42
+ )
43
+ command.add_argument(
44
+ "--url", help="Source URL for resolving references; no fetching occurs."
45
+ )
46
+ command_parsers[name] = command
47
+ command_parsers["version"] = commands.add_parser(
48
+ "version",
49
+ help="Show the installed package version.",
50
+ description="Show the installed version.",
51
+ )
52
+ help_parser = commands.add_parser(
53
+ "help",
54
+ help="Show general or command-specific help.",
55
+ description="Show help for a command.",
56
+ )
57
+ command_parsers["help"] = help_parser
58
+ help_parser.add_argument(
59
+ "topic", nargs="?", choices=command_parsers, help="Command to describe."
60
+ )
61
+ args = parser.parse_args(argv)
62
+
63
+ if args.command is None or args.command == "help":
64
+ topic = args.topic if args.command == "help" else None
65
+ (command_parsers[topic] if topic else parser).print_help()
66
+ return 0
67
+ if args.command == "version":
68
+ print(version_text)
69
+ return 0
70
+
71
+ try:
72
+ html = sys.stdin.buffer.read().decode("utf-8-sig")
73
+ if args.command == "convert":
74
+ output = convert(html, url=args.url)
75
+ else:
76
+ output = json.dumps(asdict(extract(html, url=args.url)), ensure_ascii=False, indent=2)
77
+ output += "\n"
78
+ except (OSError, UnicodeError) as error:
79
+ print(f"htomd: {error}", file=sys.stderr)
80
+ return 1
81
+
82
+ try:
83
+ sys.stdout.buffer.write(output.encode("utf-8"))
84
+ sys.stdout.buffer.flush()
85
+ except OSError as error:
86
+ # Prevent a second output failure when Python flushes stdout during shutdown.
87
+ with open(os.devnull, "wb") as sink:
88
+ os.dup2(sink.fileno(), sys.stdout.fileno())
89
+ if not isinstance(error, BrokenPipeError):
90
+ print(f"htomd: {error}", file=sys.stderr)
91
+ return 1
92
+ return 0
htomd/_metadata.py ADDED
@@ -0,0 +1,137 @@
1
+ """Read explicit metadata before cleanup, then refine it from selected content."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterator
7
+ from dataclasses import replace
8
+
9
+ from ._models import Metadata
10
+ from ._tree import Node, text_content, walk
11
+ from ._urls import safe_url
12
+
13
+ ARTICLE_TYPES = frozenset(
14
+ {
15
+ "Article",
16
+ "NewsArticle",
17
+ "BlogPosting",
18
+ "TechArticle",
19
+ "ScholarlyArticle",
20
+ "MedicalScholarlyArticle",
21
+ "Report",
22
+ "AnalysisNewsArticle",
23
+ "OpinionNewsArticle",
24
+ "ReviewNewsArticle",
25
+ "BackgroundNewsArticle",
26
+ "APIReference",
27
+ "LiveBlogPosting",
28
+ }
29
+ )
30
+
31
+
32
+ def string(value: object) -> str | None:
33
+ return value.strip() or None if isinstance(value, str) else None
34
+
35
+
36
+ def json_articles(value: object) -> Iterator[dict[str, object]]:
37
+ stack = [value]
38
+ while stack:
39
+ item = stack.pop()
40
+ if isinstance(item, list):
41
+ stack.extend(reversed(item))
42
+ elif isinstance(item, dict):
43
+ kind = item.get("@type", [])
44
+ kinds = [kind] if isinstance(kind, str) else kind
45
+ if isinstance(kinds, list) and any(
46
+ isinstance(entry, str) and entry.rsplit("/", 1)[-1] in ARTICLE_TYPES
47
+ for entry in kinds
48
+ ):
49
+ yield item
50
+ graph = item.get("@graph")
51
+ if isinstance(graph, (dict, list)):
52
+ stack.append(graph)
53
+
54
+
55
+ def author_name(value: object) -> str | None:
56
+ if isinstance(value, str):
57
+ return string(value)
58
+ if isinstance(value, dict):
59
+ return string(value.get("name"))
60
+ if isinstance(value, list):
61
+ names = [name for entry in value if (name := author_name(entry))]
62
+ return ", ".join(names) or None
63
+ return None
64
+
65
+
66
+ def read_jsonld(root: Node) -> dict[str, object]:
67
+ for node in walk(root):
68
+ if node.tag != "script" or node.attrs.get("type", "").lower() != "application/ld+json":
69
+ continue
70
+ try:
71
+ value = json.loads(text_content(node, normalize=False))
72
+ except (ValueError, RecursionError):
73
+ continue
74
+ article = next(json_articles(value), None)
75
+ if article is not None:
76
+ return article
77
+ return {}
78
+
79
+
80
+ def read_metadata(root: Node, url: str | None, base: str | None) -> Metadata:
81
+ fields: dict[str, str] = {}
82
+ title = language = canonical = None
83
+ for node in walk(root):
84
+ if node.tag == "meta":
85
+ key = node.attrs.get("property", node.attrs.get("name", "")).lower()
86
+ content = string(node.attrs.get("content"))
87
+ if content:
88
+ fields.setdefault(key, content)
89
+ elif node.tag == "title" and title is None:
90
+ title = string(text_content(node))
91
+ elif node.tag == "html":
92
+ language = string(node.attrs.get("lang") or node.attrs.get("xml:lang"))
93
+ elif node.tag == "link" and "canonical" in node.attrs.get("rel", "").lower().split():
94
+ canonical = canonical or safe_url(node.attrs.get("href", ""), base) or None
95
+ article = read_jsonld(root)
96
+ return Metadata(
97
+ title=fields.get("og:title") or title or string(article.get("headline")),
98
+ author=fields.get("author") or author_name(article.get("author")),
99
+ description=fields.get("description")
100
+ or fields.get("og:description")
101
+ or string(article.get("description")),
102
+ language=language or string(article.get("inLanguage")) or fields.get("og:locale"),
103
+ published_time=fields.get("article:published_time")
104
+ or fields.get("date")
105
+ or string(article.get("datePublished")),
106
+ url=url,
107
+ canonical_url=canonical,
108
+ )
109
+
110
+
111
+ def refine_metadata(metadata: Metadata, selected: list[Node]) -> Metadata:
112
+ heading = author = published = None
113
+ for root in selected:
114
+ for node in walk(root):
115
+ if node.tag == "h1" and heading is None:
116
+ heading = string(text_content(node))
117
+ if author is None and local_author(node):
118
+ author = string(text_content(node))
119
+ if (
120
+ published is None
121
+ and node.tag == "time"
122
+ and node.attrs.get("itemprop") != "dateModified"
123
+ ):
124
+ published = string(node.attrs.get("datetime")) or string(text_content(node))
125
+ return replace(
126
+ metadata,
127
+ title=heading or metadata.title,
128
+ author=metadata.author or author,
129
+ published_time=metadata.published_time or published,
130
+ )
131
+
132
+
133
+ def local_author(node: Node) -> bool:
134
+ if node.attrs.get("itemprop") == "author" or "author" in node.attrs.get("rel", "").split():
135
+ return True
136
+ tokens = node.attrs.get("class", "").lower().split()
137
+ return any(token in {"byline", "author", "p-author"} for token in tokens)
htomd/_models.py ADDED
@@ -0,0 +1,28 @@
1
+ """Immutable public results; the HTML tree is deliberately private."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class Metadata:
9
+ title: str | None = None
10
+ author: str | None = None
11
+ description: str | None = None
12
+ language: str | None = None
13
+ published_time: str | None = None
14
+ url: str | None = None
15
+ canonical_url: str | None = None
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class Diagnostics:
20
+ strategy: Literal["semantic", "scored", "fallback", "none"]
21
+ notes: tuple[str, ...] = ()
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class Document:
26
+ markdown: str
27
+ metadata: Metadata
28
+ diagnostics: Diagnostics
htomd/_parser.py ADDED
@@ -0,0 +1,133 @@
1
+ """Small HTMLParser-based recovery, without pretending to implement HTML5."""
2
+
3
+ from html.parser import HTMLParser
4
+
5
+ from ._tree import Node
6
+
7
+ VOID = frozenset(
8
+ [
9
+ "area",
10
+ "base",
11
+ "br",
12
+ "col",
13
+ "embed",
14
+ "hr",
15
+ "img",
16
+ "input",
17
+ "link",
18
+ "meta",
19
+ "param",
20
+ "source",
21
+ "track",
22
+ "wbr",
23
+ ]
24
+ )
25
+ P_BREAKERS = frozenset(
26
+ [
27
+ "address",
28
+ "article",
29
+ "aside",
30
+ "blockquote",
31
+ "div",
32
+ "dl",
33
+ "fieldset",
34
+ "footer",
35
+ "form",
36
+ "h1",
37
+ "h2",
38
+ "h3",
39
+ "h4",
40
+ "h5",
41
+ "h6",
42
+ "header",
43
+ "hr",
44
+ "main",
45
+ "nav",
46
+ "ol",
47
+ "p",
48
+ "pre",
49
+ "section",
50
+ "table",
51
+ "ul",
52
+ ]
53
+ )
54
+ # A matching optional end tag must be found before its enclosing scope boundary.
55
+ IMPLIED: dict[str, tuple[frozenset[str], frozenset[str]]] = {
56
+ "li": (frozenset({"li"}), frozenset({"ul", "ol"})),
57
+ "dt": (frozenset({"dt", "dd"}), frozenset({"dl"})),
58
+ "dd": (frozenset({"dt", "dd"}), frozenset({"dl"})),
59
+ "tr": (frozenset({"tr"}), frozenset({"table", "tbody", "thead", "tfoot"})),
60
+ "td": (frozenset({"td", "th"}), frozenset({"tr", "table"})),
61
+ "th": (frozenset({"td", "th"}), frozenset({"tr", "table"})),
62
+ "thead": (frozenset({"thead", "tbody", "tfoot"}), frozenset({"table"})),
63
+ "tbody": (frozenset({"thead", "tbody", "tfoot"}), frozenset({"table"})),
64
+ "tfoot": (frozenset({"thead", "tbody", "tfoot"}), frozenset({"table"})),
65
+ "option": (frozenset({"option"}), frozenset({"select", "datalist"})),
66
+ }
67
+ END_SCOPES = {"li": {"ul", "ol"}, "td": {"tr", "table"}, "th": {"tr", "table"}, "tr": {"table"}}
68
+
69
+
70
+ class TreeParser(HTMLParser):
71
+ def __init__(self) -> None:
72
+ super().__init__(convert_charrefs=True)
73
+ self.root = Node("#document")
74
+ self.stack = [self.root]
75
+ self.recoveries = 0
76
+
77
+ def close_in_scope(self, targets: frozenset[str], boundaries: frozenset[str]) -> None:
78
+ for index in range(len(self.stack) - 1, 0, -1):
79
+ tag = self.stack[index].tag
80
+ if tag in targets:
81
+ del self.stack[index:]
82
+ self.recoveries += 1
83
+ return
84
+ if tag in boundaries:
85
+ return
86
+
87
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
88
+ if tag in P_BREAKERS:
89
+ self.close_in_scope(frozenset({"p"}), frozenset({"table", "td", "th", "li"}))
90
+ if tag in IMPLIED:
91
+ self.close_in_scope(*IMPLIED[tag])
92
+ if tag == "a":
93
+ self.close_in_scope(frozenset({"a"}), frozenset({"p", "div", "li"}))
94
+ parent = self.stack[-1]
95
+ node = Node(tag, {name: value or "" for name, value in attrs}, parent=parent)
96
+ parent.children.append(node)
97
+ if tag not in VOID:
98
+ self.stack.append(node)
99
+
100
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
101
+ self.handle_starttag(tag, attrs)
102
+ if tag not in VOID:
103
+ self.handle_endtag(tag)
104
+
105
+ def handle_endtag(self, tag: str) -> None:
106
+ if tag in VOID:
107
+ return
108
+ for index in range(len(self.stack) - 1, 0, -1):
109
+ current = self.stack[index].tag
110
+ if current == tag:
111
+ del self.stack[index:]
112
+ return
113
+ if current in END_SCOPES.get(tag, set()):
114
+ break
115
+ self.recoveries += 1
116
+
117
+ def handle_data(self, data: str) -> None:
118
+ self.stack[-1].children.append(data)
119
+
120
+ def unknown_decl(self, data: str) -> None:
121
+ self.recoveries += 1
122
+
123
+
124
+ def parse(html: str) -> tuple[Node, tuple[str, ...]]:
125
+ parser = TreeParser()
126
+ try:
127
+ parser.feed(html)
128
+ parser.close()
129
+ except (AssertionError, ValueError):
130
+ # HTMLParser rejects malformed marked sections. Preserve the parsed prefix.
131
+ parser.recoveries += 1
132
+ notes = ("Recovered malformed or optionally closed HTML.",) if parser.recoveries else ()
133
+ return parser.root, notes
htomd/_render.py ADDED
@@ -0,0 +1,270 @@
1
+ """Stack-safe Markdown serialization, independent of selection heuristics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from ._selection import HEADINGS
8
+ from ._tree import SPACE, Node, elements, postorder, text_content
9
+ from ._urls import destination, safe_url
10
+
11
+ EMPHASIS = {"em": "*", "i": "*", "strong": "**", "b": "**", "s": "~~", "del": "~~", "strike": "~~"}
12
+ BLOCKS = frozenset(
13
+ {
14
+ "p",
15
+ "div",
16
+ "article",
17
+ "main",
18
+ "section",
19
+ "header",
20
+ "footer",
21
+ "figure",
22
+ "figcaption",
23
+ "address",
24
+ "details",
25
+ "summary",
26
+ "dl",
27
+ }
28
+ )
29
+ MARKUP = re.compile(r"([\\`*_~\[\]])")
30
+ BLOCK_START = re.compile(r"(^|\n)(\s*)(#{1,6}(?=\s)|[-+](?=\s)|\d+[.)](?=\s)|[=~-]{3,}(?=\s|$))")
31
+
32
+
33
+ def escape(value: str) -> str:
34
+ value = value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
35
+ value = MARKUP.sub(r"\\\1", value)
36
+ return BLOCK_START.sub(escape_block_start, value)
37
+
38
+
39
+ def escape_block_start(match: re.Match[str]) -> str:
40
+ marker = match[3]
41
+ if marker[0].isdigit():
42
+ marker = marker[:-1] + "\\" + marker[-1]
43
+ else:
44
+ marker = "\\" + marker
45
+ return match[1] + match[2] + marker
46
+
47
+
48
+ def longest_run(value: str, character: str) -> int:
49
+ return max(
50
+ (len(match[0]) for match in re.finditer(re.escape(character) + "+", value)), default=0
51
+ )
52
+
53
+
54
+ def inline_code(node: Node) -> str:
55
+ value = (
56
+ text_content(node, normalize=False)
57
+ .replace("\r\n", " ")
58
+ .replace("\r", " ")
59
+ .replace("\n", " ")
60
+ )
61
+ if not value:
62
+ return ""
63
+ delimiter = "`" * (longest_run(value, "`") + 1)
64
+ padding = (
65
+ " "
66
+ if value.startswith("`")
67
+ or value.endswith("`")
68
+ or (value.startswith(" ") and value.endswith(" ") and value.strip())
69
+ else ""
70
+ )
71
+ return delimiter + padding + value + padding + delimiter
72
+
73
+
74
+ def code_language(node: Node) -> str:
75
+ candidates = [node, *elements(node)]
76
+ if node.parent is not None:
77
+ candidates.append(node.parent)
78
+ for candidate in candidates:
79
+ for token in candidate.attrs.get("class", "").split():
80
+ if token.startswith(("language-", "lang-", "highlight-")):
81
+ value = token.split("-", 1)[1]
82
+ if re.fullmatch(r"[\w.+#-]+", value):
83
+ return value
84
+ value = candidate.attrs.get("data-language", "")
85
+ if re.fullmatch(r"[\w.+#-]+", value):
86
+ return value
87
+ return ""
88
+
89
+
90
+ def fenced_code(node: Node) -> str:
91
+ value = text_content(node, normalize=False).replace("\r\n", "\n").replace("\r", "\n")
92
+ fence = "`" * max(3, longest_run(value, "`") + 1)
93
+ ending = "" if value.endswith("\n") else "\n"
94
+ return f"\n\n{fence}{code_language(node)}\n{value}{ending}{fence}\n\n"
95
+
96
+
97
+ def join_parts(parts: list[str]) -> str:
98
+ """Coalesce block boundaries without touching whitespace inside code blocks."""
99
+ result: list[str] = []
100
+ for part in parts:
101
+ if not part:
102
+ continue
103
+ if result and result[-1].endswith("\n") and part.startswith("\n"):
104
+ result[-1] = result[-1].rstrip("\n")
105
+ part = "\n\n" + part.lstrip("\n")
106
+ result.append(part)
107
+ return "".join(result)
108
+
109
+
110
+ def wrap_inline(body: str, marker: str) -> str:
111
+ content = body.strip()
112
+ if not content:
113
+ return body
114
+ leading = " " if body[:1].isspace() else ""
115
+ trailing = " " if body[-1:].isspace() else ""
116
+ return leading + marker + content + marker + trailing
117
+
118
+
119
+ def render_link(node: Node, body: str, base: str | None) -> str:
120
+ href = safe_url(node.attrs.get("href", ""), base)
121
+ if not href or not body.strip():
122
+ return body
123
+ return f"[{body.strip()}]({destination(href)})"
124
+
125
+
126
+ def render_image(node: Node, base: str | None) -> str:
127
+ alt = escape(SPACE.sub(" ", node.attrs.get("alt", "")).strip())
128
+ source = safe_url(node.attrs.get("src", ""), base)
129
+ if not source or not node.attrs.get("src"):
130
+ return alt
131
+ return f"![{alt}]({destination(source)})"
132
+
133
+
134
+ def render_list(node: Node, rendered: dict[Node, str]) -> str:
135
+ try:
136
+ number = int(node.attrs.get("start", "1"))
137
+ except ValueError:
138
+ number = 1
139
+ items = []
140
+ for child in elements(node):
141
+ if child.tag != "li":
142
+ continue
143
+ if node.tag == "ol":
144
+ value = child.attrs.get("value", "")
145
+ if re.fullmatch(r"-?\d{1,9}", value):
146
+ number = int(value)
147
+ marker = f"{number}. " if node.tag == "ol" else "- "
148
+ lines = rendered[child].strip().splitlines()
149
+ if lines:
150
+ continuation = [" " * len(marker) + line if line else "" for line in lines[1:]]
151
+ items.append("\n".join([marker + lines[0], *continuation]))
152
+ number += 1
153
+ return "\n" + "\n".join(items) + "\n" if items else ""
154
+
155
+
156
+ def table_rows(node: Node) -> list[Node]:
157
+ rows = []
158
+ stack = list(reversed(elements(node)))
159
+ while stack:
160
+ child = stack.pop()
161
+ if child.tag == "tr":
162
+ rows.append(child)
163
+ elif child.tag in {"thead", "tbody", "tfoot"}:
164
+ stack.extend(reversed(elements(child)))
165
+ return rows
166
+
167
+
168
+ def simple_table(rows: list[list[Node]]) -> bool:
169
+ if not rows or not rows[0] or len({len(row) for row in rows}) != 1:
170
+ return False
171
+ for row in rows:
172
+ for cell in row:
173
+ if cell.attrs.get("colspan", "1") != "1" or cell.attrs.get("rowspan", "1") != "1":
174
+ return False
175
+ if any(child.tag in {"table", "pre", "ul", "ol", "p"} for child in elements(cell)):
176
+ return False
177
+ return True
178
+
179
+
180
+ def render_table(node: Node, rendered: dict[Node, str]) -> str:
181
+ rows = [
182
+ [cell for cell in elements(row) if cell.tag in {"th", "td"}] for row in table_rows(node)
183
+ ]
184
+ captions = [rendered[child].strip() for child in elements(node) if child.tag == "caption"]
185
+ if not simple_table(rows):
186
+ lines = []
187
+ for index, row in enumerate(rows, 1):
188
+ cell_values = [rendered[cell].strip().replace("\n", " ") for cell in row]
189
+ lines.append(f"{index}. " + " — ".join(cell_values))
190
+ return "\n\n" + "\n\n".join([*captions, "\n".join(lines)]) + "\n\n"
191
+ values = [
192
+ [rendered[cell].strip().replace("|", "\\|").replace("\n", " ") for cell in row]
193
+ for row in rows
194
+ ]
195
+ if not any(cell.tag == "th" for cell in rows[0]):
196
+ values.insert(0, [""] * len(rows[0]))
197
+ values.insert(1, ["---"] * len(rows[0]))
198
+ lines = ["| " + " | ".join(row) + " |" for row in values]
199
+ return "\n\n" + "\n\n".join([*captions, "\n".join(lines)]) + "\n\n"
200
+
201
+
202
+ def serialize(node: Node, body: str, rendered: dict[Node, str], base: str | None) -> str:
203
+ tag = node.tag
204
+ if tag in HEADINGS:
205
+ return "\n\n" + "#" * int(tag[1]) + " " + body.strip() + "\n\n" if body.strip() else ""
206
+ if tag in EMPHASIS:
207
+ return wrap_inline(body, EMPHASIS[tag])
208
+ if tag in {"code", "kbd", "samp"}:
209
+ return inline_code(node)
210
+ if tag == "pre":
211
+ return fenced_code(node)
212
+ if tag == "a":
213
+ return render_link(node, body, base)
214
+ if tag == "img":
215
+ return render_image(node, base)
216
+ if tag in {"ul", "ol"}:
217
+ return render_list(node, rendered)
218
+ if tag == "table":
219
+ return render_table(node, rendered)
220
+ return serialize_block(tag, body)
221
+
222
+
223
+ def serialize_block(tag: str, body: str) -> str:
224
+ if tag == "blockquote":
225
+ return (
226
+ "\n\n"
227
+ + "\n".join("> " + line if line else ">" for line in body.strip().splitlines())
228
+ + "\n\n"
229
+ )
230
+ if tag == "br":
231
+ return " \n"
232
+ if tag == "hr":
233
+ return "\n\n---\n\n"
234
+ if tag == "dt":
235
+ return "\n\n" + wrap_inline(body.strip(), "**") + "\n"
236
+ if tag == "dd":
237
+ return "\n" + body.strip() + "\n\n"
238
+ if tag in BLOCKS:
239
+ return "\n\n" + body.strip() + "\n\n" if body.strip() else ""
240
+ return body
241
+
242
+
243
+ def child_parts(node: Node, rendered: dict[Node, str]) -> list[str]:
244
+ parts = []
245
+ block_tags = BLOCKS | HEADINGS | {"ul", "ol", "pre", "table", "blockquote", "hr", "li"}
246
+ for index, child in enumerate(node.children):
247
+ if isinstance(child, Node):
248
+ parts.append(rendered[child])
249
+ continue
250
+ if child.isspace():
251
+ neighbors = (
252
+ node.children[max(0, index - 1) : index] + node.children[index + 1 : index + 2]
253
+ )
254
+ if any(isinstance(other, Node) and other.tag in block_tags for other in neighbors):
255
+ continue
256
+ parts.append(escape(SPACE.sub(" ", child)))
257
+ return parts
258
+
259
+
260
+ def render(selected: list[Node], base: str | None) -> str:
261
+ output = []
262
+ for root in selected:
263
+ rendered: dict[Node, str] = {}
264
+ for node in postorder(root):
265
+ parts = child_parts(node, rendered)
266
+ body = join_parts(parts)
267
+ rendered[node] = serialize(node, body, rendered, base)
268
+ output.append(rendered[root])
269
+ result = join_parts(output).strip()
270
+ return result + "\n" if result else ""
htomd/_selection.py ADDED
@@ -0,0 +1,339 @@
1
+ """Select content using cached evidence, then clean only the selected region."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from dataclasses import dataclass
8
+
9
+ from ._models import Diagnostics
10
+ from ._tree import Node, elements, postorder, walk
11
+
12
+ INERT = frozenset(
13
+ [
14
+ "head",
15
+ "title",
16
+ "meta",
17
+ "link",
18
+ "base",
19
+ "script",
20
+ "style",
21
+ "template",
22
+ "noscript",
23
+ "iframe",
24
+ "object",
25
+ "embed",
26
+ "svg",
27
+ "canvas",
28
+ "nav",
29
+ "button",
30
+ "input",
31
+ "select",
32
+ "textarea",
33
+ "dialog",
34
+ ]
35
+ )
36
+ NAV_ROLES = frozenset({"navigation", "banner", "contentinfo", "menu", "menubar", "dialog"})
37
+ STRUCTURES = frozenset({"ul", "ol", "dl", "table", "blockquote", "pre"})
38
+ CONTAINERS = frozenset({"article", "main", "section", "div", "body", "#document"})
39
+ EVIDENCE = frozenset({"p", "pre", "li", "dt", "dd", "td", "th", "blockquote"})
40
+ HEADINGS = frozenset({"h1", "h2", "h3", "h4", "h5", "h6"})
41
+ NEGATIVE = frozenset(
42
+ [
43
+ "advertisement",
44
+ "ads",
45
+ "advert",
46
+ "promo",
47
+ "promotion",
48
+ "related",
49
+ "share",
50
+ "sharing",
51
+ "social",
52
+ "cookie",
53
+ "consent",
54
+ "newsletter",
55
+ "comments",
56
+ "comment",
57
+ "sidebar",
58
+ "breadcrumb",
59
+ "breadcrumbs",
60
+ "pagination",
61
+ "toolbar",
62
+ "footer",
63
+ "banner",
64
+ "dropdown",
65
+ "catlinks",
66
+ "menu",
67
+ "pager",
68
+ "teaser",
69
+ "toc",
70
+ "well",
71
+ ]
72
+ )
73
+ POSITIVE = frozenset(
74
+ {"article", "content", "main", "post", "entry", "story", "text", "documentation"}
75
+ )
76
+ REFERENCES = frozenset({"footnotes", "references", "endnotes", "bibliography"})
77
+ PUNCTUATION = re.compile(r"[,.;:!?。,;:!?،؛]")
78
+ HIDDEN_STYLE = re.compile(
79
+ r"(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*(?:hidden|collapse))\s*(?:!important\s*)?(?:;|$)",
80
+ re.I,
81
+ )
82
+ # Sibling recovery needs modest support, and can never escape its parent container.
83
+ SIBLING_SCORE_RATIO = 0.18
84
+ MAX_FALLBACK_BLOCKS = 256
85
+
86
+
87
+ @dataclass(slots=True)
88
+ class Stats:
89
+ characters: int = 0
90
+ linked: int = 0
91
+ punctuation: int = 0
92
+ blocks: int = 0
93
+ headings: int = 0
94
+ code: int = 0
95
+ cells: int = 0
96
+ images: int = 0
97
+ controls: int = 0
98
+
99
+ @property
100
+ def density(self) -> float:
101
+ return self.linked / max(1, self.characters)
102
+
103
+
104
+ def hints(node: Node) -> set[str]:
105
+ value = node.attrs.get("class", "") + " " + node.attrs.get("id", "")
106
+ value = re.sub(r"([a-z])([A-Z])", r"\1 \2", value)
107
+ return set(re.findall(r"[a-z0-9]+", value.lower()))
108
+
109
+
110
+ def explicitly_hidden(node: Node) -> bool:
111
+ return (
112
+ "hidden" in node.attrs
113
+ or node.attrs.get("aria-hidden", "").lower() == "true"
114
+ or bool(HIDDEN_STYLE.search(node.attrs.get("style", "")))
115
+ )
116
+
117
+
118
+ def visible_tree(root: Node) -> Node:
119
+ """Copy visible content so metadata remains available on the original tree."""
120
+ result = Node(root.tag, root.attrs.copy())
121
+ stack = [(root, result, False)]
122
+ while stack:
123
+ original, target, local = stack.pop()
124
+ local = local or original.tag in {"article", "main"} or original.attrs.get("role") == "main"
125
+ for child in original.children:
126
+ if isinstance(child, str):
127
+ target.children.append(child)
128
+ continue
129
+ if excluded(child, local):
130
+ continue
131
+ copied = Node(child.tag, child.attrs.copy(), parent=target)
132
+ target.children.append(copied)
133
+ stack.append((child, copied, local))
134
+ return result
135
+
136
+
137
+ def excluded(node: Node, local: bool) -> bool:
138
+ if node.tag in INERT or explicitly_hidden(node):
139
+ return True
140
+ if node.tag == "a" and "headerlink" in hints(node):
141
+ return node.attrs.get("href", "").startswith("#")
142
+ role = node.attrs.get("role", "").lower()
143
+ if role in NAV_ROLES:
144
+ return True
145
+ return node.tag in {"header", "footer"} and not local
146
+
147
+
148
+ def statistics(root: Node) -> dict[Node, Stats]:
149
+ result: dict[Node, Stats] = {}
150
+ for node in postorder(root):
151
+ stat = Stats()
152
+ for child in node.children:
153
+ if isinstance(child, str):
154
+ stat.characters += len(child.strip())
155
+ stat.punctuation += len(PUNCTUATION.findall(child))
156
+ else:
157
+ other = result[child]
158
+ for field in Stats.__slots__:
159
+ setattr(stat, field, getattr(stat, field) + getattr(other, field))
160
+ stat.blocks += int(node.tag in EVIDENCE and stat.characters > 0)
161
+ stat.headings += int(node.tag in HEADINGS and stat.characters > 0)
162
+ stat.code += int(node.tag == "pre")
163
+ stat.cells += int(node.tag in {"td", "th"})
164
+ stat.images += int(node.tag == "img" and bool(node.attrs.get("src")))
165
+ stat.controls += int(node.tag in {"form", "button", "input", "select", "textarea"})
166
+ if node.tag == "a":
167
+ stat.linked = stat.characters
168
+ result[node] = stat
169
+ return result
170
+
171
+
172
+ def conditional_clutter(node: Node, stat: Stats) -> bool:
173
+ if node.tag in {"#document", "html", "body", "main", "article"}:
174
+ return False
175
+ tokens = hints(node)
176
+ if tokens & REFERENCES or node.attrs.get("role") == "note":
177
+ return False
178
+ if not tokens & NEGATIVE or node.tag in EVIDENCE | HEADINGS:
179
+ return False
180
+ # Class hints alone never delete a node: require structural corroboration.
181
+ if stat.density > 0.35 or stat.controls:
182
+ return True
183
+ if "footer" in tokens and stat.density > 0.15:
184
+ return True
185
+ if node.tag == "aside" and not stat.code and not stat.cells:
186
+ return True
187
+ if stat.blocks == 0 and stat.characters < 180:
188
+ return True
189
+ if tokens & {"comments", "comment"}:
190
+ children = elements(node)
191
+ return sum(bool(hints(child) & {"comment", "reply"}) for child in children) >= 2
192
+ return False
193
+
194
+
195
+ def clean(root: Node) -> tuple[Node, int]:
196
+ stats = statistics(root)
197
+ removed = 0
198
+ for node in walk(root):
199
+ kept: list[Node | str] = []
200
+ for child in node.children:
201
+ if isinstance(child, Node) and conditional_clutter(child, stats[child]):
202
+ removed += 1
203
+ else:
204
+ kept.append(child)
205
+ node.children = kept
206
+ return root, removed
207
+
208
+
209
+ def plausible(node: Node, stat: Stats, *, semantic: bool = False) -> bool:
210
+ if not stat.characters:
211
+ return bool(semantic and stat.images)
212
+ if stat.code or stat.cells:
213
+ return True
214
+ if node.tag == "p" and stat.characters > stat.linked:
215
+ return True
216
+ if stat.density >= 0.8:
217
+ return bool(semantic and stat.headings and stat.blocks)
218
+ return node.tag in CONTAINERS | EVIDENCE | HEADINGS | STRUCTURES or semantic
219
+
220
+
221
+ def scores(root: Node, stats: dict[Node, Stats], *, relaxed: bool) -> dict[Node, float]:
222
+ result: dict[Node, float] = {}
223
+ for node in walk(root):
224
+ stat = stats[node]
225
+ if node.tag not in CONTAINERS or not plausible(node, stat):
226
+ continue
227
+ tokens = hints(node)
228
+ score = math.sqrt(stat.characters) + min(stat.blocks, 30) * 2
229
+ score += min(stat.punctuation, 40) * 0.25 + min(stat.code + stat.cells, 12) * 2
230
+ score += 8 * bool(tokens & POSITIVE)
231
+ if not relaxed:
232
+ score -= 18 * bool(tokens & NEGATIVE)
233
+ result[node] = score * (1 - stat.density) ** 2
234
+ # Leaf evidence flows to nearby ancestors, without repeatedly counting nested blocks.
235
+ for node in walk(root):
236
+ if node.tag not in EVIDENCE or any(child.tag in EVIDENCE for child in elements(node)):
237
+ continue
238
+ stat = stats[node]
239
+ support = (1 + min(stat.characters / 100, 3)) * (1 - stat.density)
240
+ ancestor = node.parent
241
+ for weight in (1.0, 0.5, 0.25):
242
+ if ancestor is None:
243
+ break
244
+ if ancestor in result:
245
+ result[ancestor] += support * weight
246
+ ancestor = ancestor.parent
247
+ return result
248
+
249
+
250
+ def semantic_candidate(
251
+ root: Node, stats: dict[Node, Stats], ranked: dict[Node, float]
252
+ ) -> Node | None:
253
+ candidates = []
254
+ landmarks = []
255
+ for node in walk(root):
256
+ landmark = node.tag == "main" or node.attrs.get("role") == "main"
257
+ if not (landmark or node.tag == "article"):
258
+ continue
259
+ if not plausible(node, stats[node], semantic=True):
260
+ continue
261
+ if conditional_clutter(node, stats[node]):
262
+ continue
263
+ if "teaser" in hints(node) and stats[node].density > 0.1:
264
+ continue
265
+ candidates.append(node)
266
+ if landmark:
267
+ landmarks.append(node)
268
+ candidates = landmarks or candidates
269
+ if not candidates:
270
+ return None
271
+ return max(candidates, key=lambda node: ranked.get(node, 0))
272
+
273
+
274
+ def recover_siblings(
275
+ winner: Node, stats: dict[Node, Stats], ranked: dict[Node, float]
276
+ ) -> list[Node]:
277
+ parent = winner.parent
278
+ if parent is None:
279
+ return [winner]
280
+ threshold = max(5, ranked.get(winner, 0) * SIBLING_SCORE_RATIO)
281
+ selected = []
282
+ for sibling in elements(parent):
283
+ stat = stats[sibling]
284
+ if conditional_clutter(sibling, stat):
285
+ continue
286
+ supported = ranked.get(sibling, 0) >= threshold and stat.density < 0.5
287
+ heading = sibling.tag in HEADINGS and stat.density < 0.5
288
+ paragraph = sibling.tag == "p" and stat.density < 0.25 and bool(stat.characters)
289
+ if sibling is winner or supported or heading or paragraph:
290
+ selected.append(sibling)
291
+ return selected
292
+
293
+
294
+ def fallback(root: Node, stats: dict[Node, Stats]) -> list[Node]:
295
+ selected: list[Node] = []
296
+ stack = [root]
297
+ while stack and len(selected) < MAX_FALLBACK_BLOCKS:
298
+ node = stack.pop()
299
+ stat = stats[node]
300
+ if conditional_clutter(node, stat):
301
+ continue
302
+ if (
303
+ node.tag in EVIDENCE | HEADINGS | STRUCTURES
304
+ and plausible(node, stat)
305
+ or not elements(node)
306
+ and stat.characters
307
+ and stat.density < 0.5
308
+ ):
309
+ selected.append(node)
310
+ else:
311
+ stack.extend(reversed(elements(node)))
312
+ return selected
313
+
314
+
315
+ def select(root: Node) -> tuple[list[Node], Diagnostics]:
316
+ visible, removed = clean(visible_tree(root))
317
+ stats = statistics(visible)
318
+ ranked = scores(visible, stats, relaxed=False)
319
+ notes = [f"Removed {removed} conditionally identified clutter blocks."] if removed else []
320
+ winner = semantic_candidate(visible, stats, ranked)
321
+ if winner is not None:
322
+ return [winner], Diagnostics("semantic", tuple(notes))
323
+ candidates = {
324
+ node: score for node, score in ranked.items() if node.tag not in {"body", "#document"}
325
+ }
326
+ if not candidates or max(candidates.values()) < 5:
327
+ notes.append("Retried selection once with relaxed class penalties.")
328
+ ranked = scores(visible, stats, relaxed=True)
329
+ candidates = {
330
+ node: score for node, score in ranked.items() if node.tag not in {"body", "#document"}
331
+ }
332
+ if candidates and max(candidates.values()) >= 5:
333
+ winner = max(candidates, key=lambda node: candidates[node])
334
+ return recover_siblings(winner, stats, ranked), Diagnostics("scored", tuple(notes))
335
+ recovered = fallback(visible, stats)
336
+ notes.append(
337
+ "Recovered plausible blocks." if recovered else "No relevant visible content found."
338
+ )
339
+ return recovered, Diagnostics("fallback" if recovered else "none", tuple(notes))
htomd/_tree.py ADDED
@@ -0,0 +1,53 @@
1
+ """Ordered, private HTML tree and stack-safe traversals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterator
7
+ from dataclasses import dataclass, field
8
+
9
+ SPACE = re.compile(r"\s+")
10
+
11
+
12
+ @dataclass(eq=False, slots=True)
13
+ class Node:
14
+ tag: str
15
+ attrs: dict[str, str] = field(default_factory=dict)
16
+ children: list[Node | str] = field(default_factory=list)
17
+ parent: Node | None = field(default=None, repr=False)
18
+
19
+
20
+ def walk(root: Node) -> Iterator[Node]:
21
+ stack = [root]
22
+ while stack:
23
+ node = stack.pop()
24
+ yield node
25
+ stack.extend(child for child in reversed(node.children) if isinstance(child, Node))
26
+
27
+
28
+ def postorder(root: Node) -> Iterator[Node]:
29
+ stack = [(root, False)]
30
+ while stack:
31
+ node, visited = stack.pop()
32
+ if visited:
33
+ yield node
34
+ continue
35
+ stack.append((node, True))
36
+ stack.extend((child, False) for child in reversed(node.children) if isinstance(child, Node))
37
+
38
+
39
+ def text_content(root: Node, *, normalize: bool = True) -> str:
40
+ pieces: list[str] = []
41
+ stack: list[Node | str] = [root]
42
+ while stack:
43
+ item = stack.pop()
44
+ if isinstance(item, str):
45
+ pieces.append(item)
46
+ else:
47
+ stack.extend(reversed(item.children))
48
+ value = "".join(pieces)
49
+ return SPACE.sub(" ", value).strip() if normalize else value
50
+
51
+
52
+ def elements(node: Node) -> list[Node]:
53
+ return [child for child in node.children if isinstance(child, Node)]
htomd/_urls.py ADDED
@@ -0,0 +1,43 @@
1
+ """Resolve references without fetching; reject active or unknown schemes."""
2
+
3
+ import re
4
+ from urllib.parse import quote, urljoin, urlsplit
5
+
6
+ from ._tree import Node, walk
7
+
8
+ SAFE_SCHEMES = frozenset({"", "http", "https", "mailto", "tel", "ftp"})
9
+ CONTROL = re.compile(r"[\x00-\x20\x7f]+")
10
+
11
+
12
+ def safe_url(value: str, base: str | None = None) -> str | None:
13
+ value = value.strip()
14
+ # Browsers ignore ASCII control characters in schemes. Check the normalized
15
+ # spelling so an entity-encoded newline cannot disguise javascript:.
16
+ checked = CONTROL.sub("", value)
17
+ try:
18
+ if urlsplit(checked).scheme.lower() not in SAFE_SCHEMES:
19
+ return None
20
+ resolved = urljoin(base, value) if base else value
21
+ if urlsplit(CONTROL.sub("", resolved)).scheme.lower() not in SAFE_SCHEMES:
22
+ return None
23
+ except ValueError:
24
+ return None
25
+ return resolved
26
+
27
+
28
+ def document_base(root: Node, url: str | None) -> str | None:
29
+ source = safe_url(url) if url else None
30
+ for node in walk(root):
31
+ if node.tag != "base" or "href" not in node.attrs:
32
+ continue
33
+ candidate = safe_url(node.attrs["href"], source)
34
+ if candidate:
35
+ parts = urlsplit(candidate)
36
+ if parts.scheme in {"http", "https"} and parts.netloc:
37
+ return candidate
38
+ return source
39
+
40
+
41
+ def destination(value: str) -> str:
42
+ # Parentheses and whitespace must not terminate a Markdown destination.
43
+ return quote(value, safe="/:?#@!$&'*+,;=%[]~_-.")
htomd/py.typed ADDED
File without changes
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: htomd
3
+ Version: 0.1.0
4
+ Summary: Focused Markdown and metadata from messy HTML, in pure Python
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/jamiedavenport/htomd
7
+ Project-URL: Issues, https://github.com/jamiedavenport/htomd/issues
8
+ Project-URL: Changelog, https://github.com/jamiedavenport/htomd/blob/main/CHANGELOG.md
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Dynamic: license-file
19
+
20
+ # htomd
21
+
22
+ Extract Markdown and metadata from HTML. Pure Python 3.12+, with no runtime
23
+ dependencies or network access.
24
+
25
+ ## Installation
26
+
27
+ ```sh
28
+ python -m pip install htomd
29
+ ```
30
+
31
+ For the command line, install in an isolated tool environment:
32
+
33
+ ```sh
34
+ uv tool install htomd
35
+ ```
36
+
37
+ ## Python API
38
+
39
+ ```python
40
+ import htomd
41
+
42
+ html = "<article><h1>Hello</h1><p>Readable text.</p></article>"
43
+ markdown = htomd.convert(html)
44
+ document = htomd.extract(html, url="https://example.com/article")
45
+ print(document.markdown, document.metadata.title, document.diagnostics)
46
+ ```
47
+
48
+ Pass decoded HTML strings. `url` resolves relative references. `extract()` returns
49
+ immutable results; missing metadata is `None`. Empty content yields empty Markdown;
50
+ invalid argument types raise `TypeError`.
51
+
52
+ Best suited to articles and documentation. Extraction can miss content or retain
53
+ clutter. JavaScript, browser layout, math, and SVG are unsupported. Simple tables
54
+ use GFM; complex tables become row/cell text.
55
+
56
+ ## Command line
57
+
58
+ Installing the package also installs the `htomd` command. To install a locally
59
+ built wheel in a virtual environment:
60
+
61
+ ```sh
62
+ python -m venv .venv
63
+ source .venv/bin/activate
64
+ python -m pip install dist/htomd-0.1.0-py3-none-any.whl
65
+ ```
66
+
67
+ In Windows PowerShell, activate with `.venv\Scripts\Activate.ps1` instead.
68
+ See [Contributing](https://github.com/jamiedavenport/htomd/blob/main/CONTRIBUTING.md#releases)
69
+ for the build commands.
70
+
71
+ ```sh
72
+ curl -s https://example.com/article | htomd convert --url https://example.com/article
73
+ cat page.html | htomd convert > page.md
74
+ cat page.html | htomd extract > page.json
75
+ ```
76
+
77
+ Both commands read stdin to EOF and accept no file arguments. Input must be UTF-8
78
+ (an optional UTF-8 BOM is accepted); output is UTF-8. `--url` supplies source
79
+ context for relative references and metadata; it never fetches the URL.
80
+
81
+ `convert` writes Markdown unchanged. `extract` writes indented JSON containing
82
+ `markdown`, `metadata`, and `diagnostics`, matching the Python result structure.
83
+ Missing metadata is `null`; diagnostic notes are an array. Empty or malformed
84
+ HTML receives the same best-effort handling as the library.
85
+
86
+ Run `htomd`, `htomd help`, or `htomd --help` for usage and pipeline examples.
87
+ Use `htomd help convert` or `htomd convert --help` for command-specific help
88
+ (likewise for `extract`). `htomd version` or `htomd --version` prints the installed
89
+ package version. Help and version commands exit successfully without reading stdin.
90
+
91
+ `python -m htomd` supports the same commands. Successful conversion exits with code 0, I/O and
92
+ decoding failures with code 1, and usage errors with code 2. Errors go to stderr.
93
+
94
+ ## Development
95
+
96
+ ```sh
97
+ mise trust
98
+ mise install
99
+ mise run setup
100
+ mise exec -- uv run --locked pytest
101
+ ```
102
+
103
+ `mise run check` runs all checks. See
104
+ [CONTRIBUTING.md](https://github.com/jamiedavenport/htomd/blob/main/CONTRIBUTING.md)
105
+ for hooks and releases.
106
+
107
+ MIT license, copyright 2026 JXD Ltd.
108
+ [Fixtures](https://github.com/jamiedavenport/htomd/blob/main/tests/fixtures/real/README.md)
109
+ have separate licenses.
@@ -0,0 +1,17 @@
1
+ htomd/__init__.py,sha256=5jdmI8vbSl1GzcK7AINC75sUI_AGJ_gloJv96DRnu8s,1478
2
+ htomd/__main__.py,sha256=8NSrSYJ5OvYnJlgKGVngL5bpg8mmlguEyu6-YGRikuE,116
3
+ htomd/_cli.py,sha256=WlTTrmFGCEVjYrfF8FiryqhmzeejcNOCMom27vcAM00,3411
4
+ htomd/_metadata.py,sha256=v8MSFdIsrU2QHcPQwSFQ8ynUKNK7UsltJhd3tOlfyUs,4868
5
+ htomd/_models.py,sha256=wRcOs3Wldeh_yxJX5zqSQb28ORe6ZXg6ylr5tdofoH0,692
6
+ htomd/_parser.py,sha256=GSroMryc-lELfoULeUFbtMdjzPhI1cVWLXkV9MGyWY4,4119
7
+ htomd/_render.py,sha256=Q7OS7M2L9tU9BtPgBpnnaE0yxleat-by_GJ2U4BGluw,9103
8
+ htomd/_selection.py,sha256=xS2k5fHGN2hYWYUH5B9y0ZDFcgHKpCRqj_bNlknhuzI,11634
9
+ htomd/_tree.py,sha256=AnhOyo3YGE-CgU-WLcDigoOYyAwkwv9_N5pFXjXmZSQ,1504
10
+ htomd/_urls.py,sha256=gpaEms-Svr1mr3hzD_9zeWTzCmpGZbITM9ZDwrybG7o,1515
11
+ htomd/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ htomd-0.1.0.dist-info/licenses/LICENSE,sha256=VHLXTDfL3E6-kxgxwOIVcf7-8NvDOP3x-Kk6kanwWF4,1064
13
+ htomd-0.1.0.dist-info/METADATA,sha256=eVXT1zy6U7OuPVA7m3EP2mSujykAPc54DUYu2BI_NHQ,3782
14
+ htomd-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
15
+ htomd-0.1.0.dist-info/entry_points.txt,sha256=v29vVSHQxcHtaJcFMD_yFeWIrXmNoxNdiXoJgcnpqJM,42
16
+ htomd-0.1.0.dist-info/top_level.txt,sha256=EzaqcXzCQSBq-SYUbgIvrK1-c5wRXFNDrhmGcyFctNY,6
17
+ htomd-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ htomd = htomd._cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JXD Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ htomd