@ספי
import re
from pathlib import Path
import markdown
from bs4 import BeautifulSoup
from bs4.element import NavigableString
SUPPORTED_TAGS = {
"a", "abbr", "acronym", "address", "article", "aside", "audio", "b", "bdi", "bdo", "big",
"blockquote", "br", "caption", "cite", "code", "data", "dd", "del", "details", "dfn", "dl", "dt", "em", "figcaption", "figure", "footer", "font", "h1", "h2", "h3", "h4",
"h5", "h6", "header", "hr", "i", "iframe", "img", "ins", "kbd", "li", "main", "mark", "nav",
"noscript", "ol", "p", "pre", "q", "rp", "rt", "ruby", "s", "samp", "section", "small", "span",
"strike", "strong", "sub", "sup", "summary", "svg", "table", "tbody", "td", "template", "tfoot",
"th", "thead", "time", "tr", "tt", "u", "ul", "var", "video", "math", "mrow", "msup", "msub",
"mover", "munder", "msubsup", "moverunder", "mfrac", "mlongdiv", "msqrt", "mroot", "mi", "mn", "mo"
}
# Void/media tags: meaningful even with no text — never delete as "empty".
VOID_TAGS = {
"br", "hr", "img", "iframe", "svg", "video", "audio", "source",
"input", "col", "area", "embed",
}
# Tags where newlines are significant — keep line breaks as <br> instead of dropping.
PRE_TAGS = {"pre", "code", "textarea"}
MD_EXTENSIONS = ["extra", "sane_lists"]
# Block-level HTML wrappers whose inner markdown must still be parsed.
# python-markdown treats content inside these as raw unless markdown="1" is set.
_BLOCK_HTML_RE = re.compile(
r"<(div|section|article|aside|main|header|footer|nav|details|figure)\b([^>]*)>",
re.IGNORECASE,
)
def _enable_md_in_html(md_text: str) -> str:
"""Add markdown="1" to block wrappers so md_in_html parses their content."""
def add_attr(m: "re.Match[str]") -> str:
attrs = m.group(2)
if "markdown" in attrs.lower():
return m.group(0)
return f'<{m.group(1)}{attrs} markdown="1">'
return _BLOCK_HTML_RE.sub(add_attr, md_text)
def _strip_unsupported(soup: BeautifulSoup) -> None:
"""Unwrap tags not in SUPPORTED_TAGS, keeping their content."""
for tag in soup.find_all():
if tag.name not in SUPPORTED_TAGS:
tag.unwrap()
def _flatten_newlines(soup: BeautifulSoup) -> None:
"""Keep every tag on one line: newlines -> space, but -> <br> inside pre/code."""
texts = [n for n in soup.descendants if isinstance(n, NavigableString) and "\n" in n]
for text in texts:
in_pre = any(p.name in PRE_TAGS for p in text.parents)
if not in_pre:
text.replace_with(text.replace("\n", " "))
continue
new_nodes = []
for i, part in enumerate(text.strip("\n").split("\n")):
if i > 0:
new_nodes.append(soup.new_tag("br"))
new_nodes.append(NavigableString(part))
text.replace_with(*new_nodes)
def _remove_empty(soup: BeautifulSoup) -> None:
"""Drop empty tags, but keep void/media tags and any tag wrapping one."""
for tag in soup.find_all():
if tag.name in VOID_TAGS:
continue
if not tag.get_text(strip=True) and not tag.find(VOID_TAGS):
tag.decompose()
def md_to_otzaria(md_text: str) -> str:
md_text = _enable_md_in_html(md_text)
html = markdown.markdown(md_text, extensions=MD_EXTENSIONS)
soup = BeautifulSoup(html, "html.parser")
_strip_unsupported(soup)
_flatten_newlines(soup)
_remove_empty(soup)
# One top-level node per line.
lines = []
for node in soup.contents:
if isinstance(node, NavigableString):
stripped = node.strip()
if stripped:
lines.append(stripped)
else:
lines.append(str(node))
return "\n".join(lines)
def main(md_file_path: Path | str, output_file_path: Path | str | None = None) -> None:
md_file_path = Path(md_file_path)
if output_file_path is not None:
output_file_path = Path(output_file_path)
else:
output_file_path = md_file_path.with_suffix(".txt")
with md_file_path.open("r", encoding="utf-8") as f:
md_text = f.read()
otzaria_text = md_to_otzaria(md_text)
with output_file_path.open("w", encoding="utf-8") as f:
f.write(otzaria_text)
if __name__ == "__main__":
md_file = r"C:\Users\User\Downloads\README (2).md"
main(md_file)