"""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'\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"]*>(.*?)", 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 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"![{text}]({href})" return text