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""