382 lines
12 KiB
Python
382 lines
12 KiB
Python
"""HTML (1C Help) → Markdown converter (stdlib only)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from html.parser import HTMLParser
|
||
from typing import Callable
|
||
from urllib.parse import unquote
|
||
|
||
|
||
_SKIP_TAGS = frozenset({"script", "style", "head", "meta", "link"})
|
||
_HEADER = {"h1": "#", "h2": "##", "h3": "###", "h4": "####", "h5": "#####", "h6": "######"}
|
||
|
||
|
||
LinkRewriter = Callable[[str, str], str]
|
||
|
||
|
||
class _HelpHTMLParser(HTMLParser):
|
||
def __init__(self, rewrite_link: LinkRewriter | None = None) -> None:
|
||
super().__init__(convert_charrefs=True)
|
||
self.rewrite_link = rewrite_link or (lambda href, text: text)
|
||
self.parts: list[str] = []
|
||
self._skip_depth = 0
|
||
self._bold = 0
|
||
self._italic = 0
|
||
self._code = 0
|
||
self._list_stack: list[str] = []
|
||
self._li_index: list[int] = []
|
||
self._link_href: str | None = None
|
||
self._link_text: list[str] = []
|
||
self._in_anchor_name: str | None = None
|
||
self._pending_header_id: str | None = None
|
||
# single-cell callout tables (1C tip boxes)
|
||
self._table_depth = 0
|
||
self._table_buf: list[str] | None = None
|
||
self._table_cells = 0
|
||
self._outer_parts: list[str] | None = None
|
||
|
||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||
tag = tag.lower()
|
||
ad = {k.lower(): (v or "") for k, v in attrs}
|
||
|
||
if tag in _SKIP_TAGS:
|
||
self._skip_depth += 1
|
||
return
|
||
if self._skip_depth:
|
||
return
|
||
|
||
if tag == "br":
|
||
self._emit("\n")
|
||
return
|
||
if tag == "hr":
|
||
self._close_inline()
|
||
self._emit("\n\n---\n\n")
|
||
return
|
||
|
||
if tag in ("b", "strong"):
|
||
self._bold += 1
|
||
self._emit_inline("**")
|
||
return
|
||
if tag in ("i", "em"):
|
||
self._italic += 1
|
||
self._emit_inline("*")
|
||
return
|
||
if tag in ("code", "tt"):
|
||
self._code += 1
|
||
self._emit_inline("`")
|
||
return
|
||
|
||
if tag == "a":
|
||
name = ad.get("name") or ad.get("id")
|
||
href = ad.get("href", "")
|
||
if name and (not href or href.startswith("#")):
|
||
# named destination for TOC / section
|
||
self._in_anchor_name = name
|
||
self._pending_header_id = name
|
||
return
|
||
if href.startswith("v8help://"):
|
||
return
|
||
self._link_href = href
|
||
self._link_text = []
|
||
return
|
||
|
||
if tag in _HEADER:
|
||
self._close_inline()
|
||
self._ensure_blank()
|
||
if self._pending_header_id:
|
||
self._emit(f'<a id="{self._pending_header_id}"></a>\n')
|
||
self._pending_header_id = None
|
||
self._emit(f"{_HEADER[tag]} ")
|
||
return
|
||
|
||
if tag == "p":
|
||
self._close_inline()
|
||
self._ensure_blank()
|
||
return
|
||
|
||
if tag == "ul":
|
||
self._close_inline()
|
||
self._ensure_blank()
|
||
self._list_stack.append("ul")
|
||
self._li_index.append(0)
|
||
return
|
||
if tag == "ol":
|
||
self._close_inline()
|
||
self._ensure_blank()
|
||
self._list_stack.append("ol")
|
||
self._li_index.append(0)
|
||
return
|
||
if tag == "li":
|
||
self._close_inline()
|
||
self._emit("\n")
|
||
depth = max(len(self._list_stack) - 1, 0)
|
||
indent = " " * depth
|
||
if self._list_stack and self._list_stack[-1] == "ol":
|
||
self._li_index[-1] += 1
|
||
self._emit(f"{indent}{self._li_index[-1]}. ")
|
||
else:
|
||
self._emit(f"{indent}- ")
|
||
return
|
||
|
||
if tag == "table":
|
||
self._close_inline()
|
||
self._ensure_blank()
|
||
self._table_depth += 1
|
||
if self._table_depth == 1:
|
||
self._outer_parts = self.parts
|
||
self._table_buf = []
|
||
self.parts = self._table_buf
|
||
self._table_cells = 0
|
||
return
|
||
if tag == "tr":
|
||
return
|
||
if tag in ("td", "th"):
|
||
if self._table_depth:
|
||
self._table_cells += 1
|
||
return
|
||
|
||
def handle_endtag(self, tag: str) -> None:
|
||
tag = tag.lower()
|
||
if tag in _SKIP_TAGS:
|
||
if self._skip_depth:
|
||
self._skip_depth -= 1
|
||
return
|
||
if self._skip_depth:
|
||
return
|
||
|
||
if tag in ("b", "strong") and self._bold:
|
||
self._emit_inline("**")
|
||
self._bold -= 1
|
||
return
|
||
if tag in ("i", "em") and self._italic:
|
||
self._emit_inline("*")
|
||
self._italic -= 1
|
||
return
|
||
if tag in ("code", "tt") and self._code:
|
||
self._emit_inline("`")
|
||
self._code -= 1
|
||
return
|
||
|
||
if tag == "a":
|
||
if self._in_anchor_name is not None:
|
||
self._in_anchor_name = None
|
||
return
|
||
if self._link_href is None:
|
||
return
|
||
text = "".join(self._link_text)
|
||
# tidy bold spaces inside link text
|
||
text = re.sub(r"\*\*\s+", "**", text)
|
||
text = re.sub(r"\s+\*\*", "**", text)
|
||
text = text.strip()
|
||
href = self._link_href
|
||
self._link_href = None
|
||
self._link_text = []
|
||
if not text and not href:
|
||
return
|
||
self._emit(self.rewrite_link(href, text or href))
|
||
return
|
||
|
||
if tag in _HEADER:
|
||
self._pending_header_id = None
|
||
self._emit("\n\n")
|
||
return
|
||
if tag in ("p", "div"):
|
||
self._emit("\n\n")
|
||
return
|
||
if tag in ("ul", "ol"):
|
||
if self._list_stack:
|
||
self._list_stack.pop()
|
||
if self._li_index:
|
||
self._li_index.pop()
|
||
self._emit("\n\n")
|
||
return
|
||
if tag == "li":
|
||
return
|
||
|
||
if tag == "table":
|
||
if self._table_depth == 1 and self._table_buf is not None and self._outer_parts is not None:
|
||
body = "".join(self._table_buf).strip()
|
||
body = re.sub(r"^`+|`+$", "", body).strip()
|
||
self.parts = self._outer_parts
|
||
self._table_buf = None
|
||
self._outer_parts = None
|
||
self._table_depth = 0
|
||
if body:
|
||
# 1C tip/note box → blockquote
|
||
quoted = "\n".join(
|
||
f"> {line}" if line.strip() else ">"
|
||
for line in body.splitlines()
|
||
)
|
||
self._emit(quoted + "\n\n")
|
||
self._table_cells = 0
|
||
return
|
||
if self._table_depth:
|
||
self._table_depth -= 1
|
||
return
|
||
|
||
def handle_data(self, data: str) -> None:
|
||
if self._skip_depth or not data:
|
||
return
|
||
if self._link_href is not None:
|
||
self._link_text.append(data)
|
||
return
|
||
self._emit(data)
|
||
|
||
def _emit_inline(self, s: str) -> None:
|
||
"""Bold/italic/code markers: stay inside link buffer when collecting a link."""
|
||
if self._link_href is not None:
|
||
self._link_text.append(s)
|
||
else:
|
||
self.parts.append(s)
|
||
|
||
def _emit(self, s: str) -> None:
|
||
self.parts.append(s)
|
||
|
||
def _ensure_blank(self) -> None:
|
||
text = "".join(self.parts)
|
||
if not text.endswith("\n\n"):
|
||
if text.endswith("\n"):
|
||
self.parts.append("\n")
|
||
elif text:
|
||
self.parts.append("\n\n")
|
||
|
||
def _close_inline(self) -> None:
|
||
while self._bold:
|
||
self._emit_inline("**")
|
||
self._bold -= 1
|
||
while self._italic:
|
||
self._emit_inline("*")
|
||
self._italic -= 1
|
||
while self._code:
|
||
self._emit_inline("`")
|
||
self._code -= 1
|
||
|
||
|
||
def _normalize_md(text: str) -> str:
|
||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||
text = re.sub(r"[ \t]+\n", "\n", text)
|
||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||
# empty bold
|
||
text = re.sub(r"\*\*\s*\*\*", "", text)
|
||
# **Товар **– → **Товар** – (trailing spaces move outside bold)
|
||
def _fix_bold(m: re.Match[str]) -> str:
|
||
inner = m.group(1)
|
||
core = inner.rstrip()
|
||
pad = inner[len(core) :]
|
||
return f"**{core}**{pad}"
|
||
|
||
text = re.sub(r"\*\*([^*\n]+)\*\*", _fix_bold, text)
|
||
text = text.replace("****", "")
|
||
return text.strip() + "\n"
|
||
|
||
|
||
def extract_h1(html: str) -> str | None:
|
||
m = re.search(r"<h1[^>]*>(.*?)</h1>", html, flags=re.IGNORECASE | re.DOTALL)
|
||
if not m:
|
||
return None
|
||
raw = re.sub(r"<[^>]+>", "", m.group(1))
|
||
raw = re.sub(r"\s+", " ", raw).strip()
|
||
return raw or None
|
||
|
||
|
||
def html_to_markdown(
|
||
html: str,
|
||
*,
|
||
rewrite_link: LinkRewriter | None = None,
|
||
strip_first_h1: str | None = None,
|
||
) -> str:
|
||
"""Convert 1C help HTML to GitHub/Gitea-flavoured Markdown."""
|
||
parser = _HelpHTMLParser(rewrite_link=rewrite_link)
|
||
parser.feed(html)
|
||
parser.close()
|
||
md = _normalize_md("".join(parser.parts))
|
||
|
||
if strip_first_h1:
|
||
esc = re.escape(strip_first_h1.strip())
|
||
md = re.sub(rf"^#\s+{esc}\s*\n+", "", md, count=1, flags=re.IGNORECASE)
|
||
|
||
md = re.sub(r"\n\|\s*\n", "\n", md)
|
||
md = _toc_paragraphs_to_list(md)
|
||
return _normalize_md(md)
|
||
|
||
|
||
_TOC_LINK_LINE = re.compile(r"^\[([^\]]+)\]\(#([^)]+)\)$")
|
||
|
||
|
||
def _toc_paragraphs_to_list(md: str) -> str:
|
||
"""Turn leading consecutive [text](#id) paragraphs into a bullet TOC."""
|
||
lines = md.split("\n")
|
||
out: list[str] = []
|
||
i = 0
|
||
# skip leading blanks
|
||
while i < len(lines) and not lines[i].strip():
|
||
out.append(lines[i])
|
||
i += 1
|
||
# keep intro paragraphs until we hit TOC-looking run
|
||
# Heuristic: a run of ≥2 lines that are only markdown fragment links
|
||
while i < len(lines):
|
||
# collect potential TOC run starting at i (allow blank lines inside)
|
||
j = i
|
||
toc_items: list[str] = []
|
||
while j < len(lines):
|
||
s = lines[j].strip()
|
||
if not s:
|
||
# peek if next non-empty is still TOC
|
||
k = j + 1
|
||
while k < len(lines) and not lines[k].strip():
|
||
k += 1
|
||
if k < len(lines) and _TOC_LINK_LINE.match(lines[k].strip()):
|
||
j = k
|
||
continue
|
||
break
|
||
if _TOC_LINK_LINE.match(s) and not s.startswith("#"):
|
||
toc_items.append(s)
|
||
j += 1
|
||
continue
|
||
break
|
||
if len(toc_items) >= 2:
|
||
for item in toc_items:
|
||
out.append(f"- {item}")
|
||
out.append("")
|
||
i = j
|
||
continue
|
||
out.append(lines[i])
|
||
i += 1
|
||
return "\n".join(out)
|
||
|
||
|
||
def default_link_rewrite(href: str, text: str, meta_to_title: dict[str, str]) -> str:
|
||
"""Rewrite href into markdown / wiki link."""
|
||
href = href.strip()
|
||
text = text.strip() or href
|
||
|
||
if href.startswith("#"):
|
||
# in-page TOC → markdown link to <a id="…">
|
||
anchor = href[1:]
|
||
plain = text.replace("**", "").replace("*", "").strip()
|
||
if anchor:
|
||
return f"[{plain}](#{anchor})"
|
||
return plain
|
||
|
||
if href.startswith(("http://", "https://", "mailto:")):
|
||
return f"[{text}]({href})"
|
||
|
||
m = re.match(
|
||
r"^([A-Za-z]+(?:\.[^/#\s]+)+)/Help(?:#(.*))?$",
|
||
unquote(href),
|
||
)
|
||
if m:
|
||
meta_key = m.group(1)
|
||
title = meta_to_title.get(meta_key)
|
||
if title:
|
||
if text == title or text == meta_key:
|
||
return f"[[{title}]]"
|
||
return f"[[{text}|{title}]]"
|
||
return f"{text} (`{meta_key}`)"
|
||
|
||
if re.search(r"\.(png|jpe?g|gif|webp|bmp)$", href, re.I):
|
||
return f""
|
||
|
||
return text
|