"""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 _BLOCK_TAGS = frozenset( {"p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "tr", "blockquote", "pre"} ) _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] = [] # "ul" | "ol" self._li_index: list[int] = [] self._link_href: str | None = None self._link_text: list[str] = [] self._in_anchor_name: str | None = None self.title: str | None = None self._pending_break = False 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("**") return if tag in ("i", "em"): self._italic += 1 self._emit("*") return if tag in ("code", "tt"): self._code += 1 self._emit("`") 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 anchor — keep as HTML comment / empty target for TOC self._in_anchor_name = 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() 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 in ("table", "tbody", "thead"): self._close_inline() self._ensure_blank() return if tag == "tr": self._emit("\n") return if tag in ("td", "th"): self._emit(" | ") 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("**") self._bold -= 1 return if tag in ("i", "em") and self._italic: self._emit("*") self._italic -= 1 return if tag in ("code", "tt") and self._code: self._emit("`") 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).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: # capture first h1 as title # title extracted later from markdown 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 def handle_data(self, data: str) -> None: if self._skip_depth: return if self._link_href is not None: self._link_text.append(data) return # collapse whitespace inside blocks but keep intentional spaces if not data: return self._emit(data) 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.parts.append("**") self._bold -= 1 while self._italic: self.parts.append("*") self._italic -= 1 while self._code: self.parts.append("`") 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) # fix bold/italic glued spaces text = re.sub(r"\*\*\s+\*\*", "", text) 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: # remove leading "# Title\n" if it matches page title esc = re.escape(strip_first_h1.strip()) md = re.sub(rf"^#\s+{esc}\s*\n+", "", md, count=1, flags=re.IGNORECASE) # drop empty ITS-only stubs that are just TOC junk md = re.sub(r"\n\|\s*\n", "\n", md) return _normalize_md(md) 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("#"): # keep plain text for in-page TOC (anchors rarely useful in wiki MD) return text if href.startswith(("http://", "https://", "mailto:")): return f"[{text}]({href})" # Catalog.X/Help or Catalog.X.Form.Y/Help[#anchor] 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}`)" # relative image or unknown if re.search(r"\.(png|jpe?g|gif|webp|bmp)$", href, re.I): return f"![{text}]({href})" return text