From c32fce2b5ff1d6b3372791ce2a05f8868ea29c7a Mon Sep 17 00:00:00 2001 From: mihailkudravcev Date: Thu, 23 Jul 2026 16:24:41 +0300 Subject: [PATCH] Fix 1C help HTML to Markdown for TOC, bold-in-links, and tip tables. --- CHANGELOG.md | 9 +++ VERSION | 2 +- export1c_help/convert.py | 170 +++++++++++++++++++++++++++++++-------- 3 files changed, 146 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23d369e..08bc9e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to **export_1c_help** are documented in this file. +## [0.3.2] - 2026-07-23 + +### Fixed + +- HTML→MD: bold inside links no longer produces `****` +- TOC `#anchor` links preserved (`[text](#id)` + `` before headings) +- Single-cell tip `…` converted to blockquote instead of broken `| …` +- Trailing spaces inside `` moved outside bold (`**Товар** –`) + ## [0.3.1] - 2026-07-23 ### Fixed diff --git a/VERSION b/VERSION index 9e11b32..d15723f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.1 +0.3.2 diff --git a/export1c_help/convert.py b/export1c_help/convert.py index 4f99fdc..4bc0408 100644 --- a/export1c_help/convert.py +++ b/export1c_help/convert.py @@ -8,9 +8,6 @@ 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": "######"} @@ -27,13 +24,17 @@ class _HelpHTMLParser(HTMLParser): self._bold = 0 self._italic = 0 self._code = 0 - self._list_stack: list[str] = [] # "ul" | "ol" + 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.title: str | None = None - self._pending_break = False + 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() @@ -52,39 +53,48 @@ class _HelpHTMLParser(HTMLParser): self._close_inline() self._emit("\n\n---\n\n") return + if tag in ("b", "strong"): self._bold += 1 - self._emit("**") + self._emit_inline("**") return if tag in ("i", "em"): self._italic += 1 - self._emit("*") + self._emit_inline("*") return if tag in ("code", "tt"): self._code += 1 - self._emit("`") + 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 anchor — keep as HTML comment / empty target for TOC + # 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() @@ -108,15 +118,22 @@ class _HelpHTMLParser(HTMLParser): else: self._emit(f"{indent}- ") return - if tag in ("table", "tbody", "thead"): + + 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": - self._emit("\n") return if tag in ("td", "th"): - self._emit(" | ") + if self._table_depth: + self._table_cells += 1 return def handle_endtag(self, tag: str) -> None: @@ -129,24 +146,29 @@ class _HelpHTMLParser(HTMLParser): return if tag in ("b", "strong") and self._bold: - self._emit("**") + self._emit_inline("**") self._bold -= 1 return if tag in ("i", "em") and self._italic: - self._emit("*") + self._emit_inline("*") self._italic -= 1 return if tag in ("code", "tt") and self._code: - self._emit("`") + 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).strip() + 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 = [] @@ -154,9 +176,9 @@ class _HelpHTMLParser(HTMLParser): 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._pending_header_id = None self._emit("\n\n") return if tag in ("p", "div"): @@ -172,17 +194,42 @@ class _HelpHTMLParser(HTMLParser): 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: + if self._skip_depth or not data: 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_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) @@ -196,13 +243,13 @@ class _HelpHTMLParser(HTMLParser): def _close_inline(self) -> None: while self._bold: - self.parts.append("**") + self._emit_inline("**") self._bold -= 1 while self._italic: - self.parts.append("*") + self._emit_inline("*") self._italic -= 1 while self._code: - self.parts.append("`") + self._emit_inline("`") self._code -= 1 @@ -210,8 +257,17 @@ 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) + # 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" @@ -237,28 +293,75 @@ def html_to_markdown( 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) + 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("#"): - # keep plain text for in-page TOC (anchors rarely useful in wiki MD) - return text + # 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})" - # Catalog.X/Help or Catalog.X.Form.Y/Help[#anchor] m = re.match( r"^([A-Za-z]+(?:\.[^/#\s]+)+)/Help(?:#(.*))?$", unquote(href), @@ -272,7 +375,6 @@ def default_link_rewrite(href: str, text: str, meta_to_title: dict[str, str]) -> 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})"