Пакет D модуля wiki: перевод HTML, вложения, синхронизация, MCP, картинки в тексте (#135) #136
@ -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("<", "<")
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@ -274,3 +274,64 @@ def test_plain_at_sign_kept():
|
||||
def test_code_keeps_literal_text():
|
||||
md, _ = convert.to_markdown("<p><code><script> 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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user