Files
2026-07-29 11:12:38 +03:00

388 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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_page: dict[str, tuple[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)
page = meta_to_page.get(meta_key)
if page:
title, filename = page
label = text
if text == meta_key:
label = title
return f"[{label}]({filename})"
return f"{text} (`{meta_key}`)"
if re.search(r"\.(png|jpe?g|gif|webp|bmp)$", href, re.I):
return f"![{text}]({href})"
return text