diff --git a/src/sdlc_hub/modules/wiki/convert.py b/src/sdlc_hub/modules/wiki/convert.py
index b6c24cf..4abf258 100644
--- a/src/sdlc_hub/modules/wiki/convert.py
+++ b/src/sdlc_hub/modules/wiki/convert.py
@@ -21,6 +21,7 @@ _PRIVATE = re.compile("[]")
_NAME_EDGE = re.compile(r"(? str:
return text.replace("<", "<")
+def _tail(text: str) -> str:
+ """Хвостовая «\\» экранировала бы закрывающую скобку ссылки: нечётная серия в конце удваивается (T6.5)."""
+ m = _TAIL.search(text)
+ return text + "\\" if m and len(m.group(0)) % 2 else text
+
+
def _label(text: str) -> str:
- """Текст ссылки на страницу: уже экранирован markdownify, здесь только скобки и переводы строк."""
- return _BRACKET.sub(r"\\\1", _NEWLINES.sub(" ", text))
+ """Текст ссылки на страницу: уже экранирован markdownify, здесь скобки, переводы строк и хвостовая «\\»."""
+ return _tail(_BRACKET.sub(r"\\\1", _NEWLINES.sub(" ", text)))
+
+
+def _href(url: str) -> str:
+ """Пробел и круглые скобки ломают разбор `[текст](адрес)`: кодируются; прочее — как есть (T6.4)."""
+ return url.replace(" ", "%20").replace("(", "%28").replace(")", "%29")
class _Converter(MarkdownConverter):
def __init__(self, attachments, links, **options):
super().__init__(**options)
- self._by_ref = {a["ref"]: a for a in attachments}
- self._links = {l["ref"]: l["code"] for l in links}
+ self._by_ref, self._links = {}, {}
+ for a in attachments: # при повторе ref действует первая запись (T6.3)
+ self._by_ref.setdefault(a["ref"], a)
+ for l in links:
+ self._links.setdefault(l["ref"], l["code"])
self.files = [] # [{url, name}] без повторов по url, в порядке первого появления
self._index = {} # url -> индекс в files
+ self._frags = [] # выданные ссылки вложений: по ним из текста выносятся вложенные вложения
def escape(self, text, parent_tags):
# «<» в тексте автора не должен выглядеть как тег; «@» не экранируется
@@ -61,35 +77,49 @@ class _Converter(MarkdownConverter):
return "" # миниатюра и файл одного вложения: вторая ссылка удаляется
self._index[url] = len(self.files)
self.files.append({"url": url, "name": entry["name"]})
- return "[📎 %s%s%d%s" % (_name(entry["name"]), _MARK_OPEN, self._index[url], _MARK_CLOSE)
+ frag = "[📎 %s%s%d%s" % (_name(entry["name"]), _MARK_OPEN, self._index[url], _MARK_CLOSE)
+ self._frags.append(frag)
+ return frag
+
+ def _split_marks(self, text: str) -> tuple[str, str]:
+ """(ссылки вложений из текста подряд, остаток текста): ссылка внутри ссылки в Markdown невозможна (T6.2)."""
+ found = sorted((text.find(f), f) for f in self._frags if f in text)
+ for _, f in found:
+ text = text.replace(f, "", 1)
+ return "".join(f for _, f in found), text
def convert_img(self, el, text, parent_tags):
src = el.get("src") or ""
if src.startswith(_STATIC):
return ""
- parent = el.parent
- if parent is not None and parent.name == "a" and (parent.get("href") or "") in self._by_ref:
- return "" # картинка внутри ссылки на вложение: ссылку выводит
entry = self._by_ref.get(src)
+ anchor = el.find_parent("a") # на любой глубине:
(T6.1)
+ outer = self._by_ref.get(anchor.get("href") or "") if anchor is not None else None
+ if outer is not None and (entry is None or entry["url"] == outer["url"]):
+ return "" # картинка того же вложения внутри ссылки на него: ссылку выводит
if entry is not None:
return self._attachment(entry)
if _unsafe(src):
return ""
+ el["src"] = _href(src)
return super().convert_img(el, text, parent_tags)
def convert_a(self, el, text, parent_tags):
href = el.get("href") or ""
if href.startswith(_STATIC):
return ""
+ marks, rest = self._split_marks(text)
entry = self._by_ref.get(href)
if entry is not None:
- return self._attachment(entry)
+ return marks + self._attachment(entry)
+ sep = " " if marks else ""
code = self._links.get(href)
if code is not None:
- return "[%s](page:%s)" % (_label(text.strip()) or _name(code), code)
+ return marks + sep + "[%s](page:%s)" % (_label(rest.strip()) or _name(code), code)
if _unsafe(href):
return text # исполняемая или внутренняя ссылка автора: остаётся только текст
- return super().convert_a(el, text, parent_tags)
+ el["href"] = _href(href)
+ return marks + sep + super().convert_a(el, _tail(rest.strip()) or _label(href), parent_tags)
def _strip_private(soup) -> None:
diff --git a/tests/test_wiki_convert.py b/tests/test_wiki_convert.py
index ab3759e..a1ab96a 100644
--- a/tests/test_wiki_convert.py
+++ b/tests/test_wiki_convert.py
@@ -274,3 +274,64 @@ def test_plain_at_sign_kept():
def test_code_keeps_literal_text():
md, _ = convert.to_markdown("<script> a@b
", [], [])
assert "`