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