Пакет D модуля wiki: перевод HTML, вложения, синхронизация, MCP, картинки в тексте (#135) #136

Merged
aep merged 9 commits from feat/135-wiki-package-d into main 2026-10-27 18:02:27 +03:00
2 changed files with 102 additions and 11 deletions
Showing only changes of commit 6cc4b9a6b9 - Show all commits

View File

@ -21,6 +21,7 @@ _PRIVATE = re.compile("[]")
_NAME_EDGE = re.compile(r"(?<!\w)[_*]|[_*](?!\w)") # «_» и «*» на границе слова; внутри слова — как есть
_BRACKET = re.compile(r"(?<!\\)([\[\]])")
_ATTACH_LINK = re.compile(r"\]\(attachment:@(\d+)\)")
_TAIL = re.compile(r"\\+$")
_MARK_OPEN, _MARK_CLOSE = "", ""
_MARK = re.compile("(\\d+)")
@ -38,18 +39,33 @@ def _name(text: str) -> str:
return text.replace("<", "&lt;")
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 = [] # выданные ссылки вложений: по ним из текста <a> выносятся вложенные вложения
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 "" # картинка внутри ссылки на вложение: ссылку выводит <a>
entry = self._by_ref.get(src)
anchor = el.find_parent("a") # на любой глубине: <a><span><img></span></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 "" # картинка того же вложения внутри ссылки на него: ссылку выводит <a>
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:

View File

@ -274,3 +274,64 @@ def test_plain_at_sign_kept():
def test_code_keeps_literal_text():
md, _ = convert.to_markdown("<p><code>&lt;script&gt; a@b</code></p>", [], [])
assert "`<script> a@b`" in md
# пакет D (#135): T6.1–T6.5
IMG_URL = "https://eva.example.test" + IMG_REF
FILE = {"ref": FILE_REF, "url": FILE_URL, "name": "model.xlsx"}
THUMB = {"ref": THUMB_REF, "url": FILE_URL, "name": "model.xlsx"}
IMG = {"ref": IMG_REF, "url": IMG_URL, "name": "scheme.png"}
LINK = {"ref": "project/Document/DOC-1", "code": "DOC-1"}
def test_thumbnail_in_wrapper_keeps_link():
md, atts = convert.to_markdown(f'<p><a href="{FILE_REF}"><span><img src="{THUMB_REF}"></span></a> текст</p>', [THUMB, FILE], [])
assert md == "[📎 model.xlsx](attachment:@0) текст\n" and len(atts) == 1
def test_other_attachment_inside_attachment_link():
md, atts = convert.to_markdown(f'<p><a href="{FILE_REF}"><img src="{IMG_REF}"></a></p>', [FILE, IMG], [])
assert md == "[📎 scheme.png](attachment:@0)[📎 model.xlsx](attachment:@1)\n"
assert [a["name"] for a in atts] == ["scheme.png", "model.xlsx"]
def test_attachment_inside_page_link():
md, _ = convert.to_markdown(f'<p><a href="project/Document/DOC-1"><img src="{IMG_REF}"></a></p>', [IMG], [LINK])
assert md == "[📎 scheme.png](attachment:@0) [DOC-1](page:DOC-1)\n"
def test_attachment_and_text_inside_page_link():
md, _ = convert.to_markdown(f'<p><a href="project/Document/DOC-1"><img src="{IMG_REF}">Схема</a></p>', [IMG], [LINK])
assert md == "[📎 scheme.png](attachment:@0) [Схема](page:DOC-1)\n"
def test_attachment_inside_external_link():
md, _ = convert.to_markdown(f'<p><a href="https://ex.com/x"><img src="{IMG_REF}"></a></p>', [IMG], [])
assert md == "[📎 scheme.png](attachment:@0) [https://ex.com/x](https://ex.com/x)\n"
def test_duplicate_ref_first_wins():
md, atts = convert.to_markdown(f'<p><img src="{IMG_REF}"></p>', [dict(IMG, name="first"), dict(IMG, url="u2", name="second")], [])
assert atts == [{"url": IMG_URL, "name": "first"}]
md, _ = convert.to_markdown('<p><a href="project/Document/DOC-1">т</a></p>', [], [LINK, dict(LINK, code="DOC-9")])
assert "(page:DOC-1)" in md
def test_external_href_spaces_and_parens_encoded():
md, _ = convert.to_markdown('<p><a href="http://ex.com/a b(c).html">док</a></p>', [], [])
assert md == "[док](http://ex.com/a%20b%28c%29.html)\n"
def test_external_href_already_encoded_untouched():
md, _ = convert.to_markdown('<p><a href="http://ex.com/a%20b.html">док</a></p>', [], [])
assert "(http://ex.com/a%20b.html)" in md and "%2520" not in md
def test_trailing_backslash_in_page_link_text():
md, _ = convert.to_markdown('<p><a href="project/Document/DOC-1">путь C:\\</a></p>', [], [LINK])
assert md == "[путь C:\\\\](page:DOC-1)\n"
def test_trailing_backslash_in_external_link_text():
md, _ = convert.to_markdown('<p><a href="http://ex.com/">путь C:\\</a></p>', [], [])
assert md == "[путь C:\\\\](http://ex.com/)\n"