File size: 7,309 Bytes
21cde0c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d55a0af
21cde0c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
"""Telegram formatting <-> the HTML a caption is stored as.

People format a caption like any message: select words and make them bold, italic, a link...  Telegram delivers that
as *entities* (kind, offset, length) next to the plain text.  Captions are kept as Telegram HTML (the variables are
filled in and the HTML is sent with ``parse_mode="html"``), so ``from_entities`` turns the entities into that HTML and
nobody has to type a tag, and ``preview`` turns stored HTML back into something that is safe to show in a panel.

Entity offsets count UTF-16 code units, so the text is handled the way Telethon does it: an emoji outside the basic
plane is two "surrogate" characters while the work is going on.
"""
from __future__ import annotations

import html
import re
from typing import Any, Dict, Iterable, List, NamedTuple, Optional, Tuple

from telethon.extensions import html as telegram_html
from telethon.helpers import add_surrogate, del_surrogate
from telethon.tl import types

from .parser import CAPTION_VARS
from .utils import esc

_VARIABLE = re.compile(r"\{(?:%s)\}" % "|".join(map(re.escape, CAPTION_VARS)))

_SIMPLE = {
    types.MessageEntityBold: ("<b>", "</b>"),
    types.MessageEntityItalic: ("<i>", "</i>"),
    types.MessageEntityUnderline: ("<u>", "</u>"),
    types.MessageEntityStrike: ("<s>", "</s>"),
    types.MessageEntityCode: ("<code>", "</code>"),
}


class Span(NamedTuple):
    start: int
    end: int
    open: str
    close: str


def _tags(entity: Any) -> Optional[Tuple[str, str]]:
    """The HTML tags for one entity.  ``None`` for the ones that are not formatting the bot keeps: what Telegram finds by
    itself (links in the text, @mentions, #hashtags, /commands) comes back by itself when the caption is sent.  Spoilers and
    custom emoji are not kept either: Telethon's HTML has no spoiler, and a bot may not send custom emoji."""
    kind = type(entity)
    if kind in _SIMPLE:
        return _SIMPLE[kind]
    if kind is types.MessageEntityTextUrl:
        return f'<a href="{html.escape(entity.url, quote=True)}">', "</a>"
    if kind is types.MessageEntityMentionName:
        return f'<a href="tg://user?id={entity.user_id}">', "</a>"
    if kind is types.MessageEntityPre:
        language = html.escape(getattr(entity, "language", "") or "", quote=True)
        return (f'<pre><code class="language-{language}">', "</code></pre>") if language else ("<pre>", "</pre>")
    if kind is types.MessageEntityBlockquote:
        return ("<blockquote expandable>" if getattr(entity, "collapsed", False) else "<blockquote>"), "</blockquote>"
    return None


def _whole(body: str, at: int) -> int:
    """``at``, moved off the middle of a surrogate pair."""
    if 0 < at < len(body) and "\udc00" <= body[at] <= "\udfff" and "\ud800" <= body[at - 1] <= "\udbff":
        return at + 1
    return at


def _tidy(spans: Iterable[Span]) -> List[Span]:
    """Spans of one kind never overlap afterwards.  The HTML parser can't nest a tag in itself (``<b>a<b>b</b>c</b>`` loses
    its outer one), so identical spans that overlap or touch are joined, and of two links (or other tags with different
    details) that overlap, the one that starts later only gets what the first leaves."""
    groups: Dict[str, List[Span]] = {}
    for s in spans:
        groups.setdefault(re.match(r"<(\w+)", s.open).group(1), []).append(s)
    out: List[Span] = []
    for group in groups.values():
        kept: List[Span] = []
        for s in sorted(group, key=lambda s: (s.start, -s.end)):
            if kept and s.start <= kept[-1].end:
                last = kept[-1]
                if s.open == last.open:
                    kept[-1] = last._replace(end=max(last.end, s.end))
                    continue
                if s.start < last.end:
                    s = s._replace(start=last.end)
                    if s.end <= s.start:
                        continue
            kept.append(s)
        out += kept
    return out


def _render(body: str, spans: Iterable[Span]) -> str:
    """``body`` as HTML with the spans' tags in it.  Spans that overlap without one containing the other are split, so
    that the tags always nest."""
    spans = [s._replace(start=_whole(body, s.start), end=_whole(body, s.end)) for s in spans]
    spans = _tidy(s for s in spans if s.end > s.start)
    points = sorted({0, len(body)} | {p for s in spans for p in (s.start, s.end)})
    out: List[str] = []
    running: List[Span] = []  # the spans that are open right now, outermost first
    for index, at in enumerate(points):
        ending = [s for s in running if s.end == at]
        if ending:
            depth = min(running.index(s) for s in ending)
            inner, running = running[depth:], running[:depth]
            out += [s.close for s in reversed(inner)]
            for s in inner:
                if s.end != at:  # it goes on past this point: close it around the one that ended and open it again
                    out.append(s.open)
                    running.append(s)
        for s in sorted((s for s in spans if s.start == at), key=lambda s: -s.end):  # on a tie the earlier one stays outside
            out.append(s.open)
            running.append(s)
        if index + 1 < len(points):
            out.append(esc(body[at:points[index + 1]]))
    return "".join(out)


def from_entities(text: str, entities: Optional[Iterable[Any]], start: int = 0) -> Optional[str]:
    """``text[start:]`` as HTML that carries the formatting in ``entities``, or ``None`` when there is none to keep.

    ``start`` skips what comes before the caption (a command word, say).  A ``{variable}`` is never split: a format that
    covers only part of one (double-tapping the word inside the braces does that) is widened to the whole variable, because
    the variable is replaced as a whole.
    """
    if not text or not entities:
        return None
    first = len(add_surrogate(text[:start]))
    body = add_surrogate(text)[first:]
    first += len(body) - len(body.lstrip())
    body = body.strip()
    variables = [(m.start(), m.end()) for m in _VARIABLE.finditer(body)]
    spans = []
    for entity in entities:
        tags = _tags(entity)
        if tags is None:
            continue
        a, b = max(entity.offset - first, 0), min(entity.offset + entity.length - first, len(body))
        if b <= a:
            continue
        for lo, hi in variables:
            if a < hi and b > lo:
                a, b = min(a, lo), max(b, hi)
        spans.append(Span(a, b, *tags))
    return del_surrogate(_render(body, spans)) if spans else None


def preview(template: str, limit: int) -> str:
    """A stored caption template as HTML that is safe to show in a panel: its formatting is shown, its tags are balanced
    whatever was typed, and it is cut to ``limit`` characters."""
    text, entities = telegram_html.parse(template or "")
    if not text:
        return "—"
    body = add_surrogate(text)
    cut = len(body) if len(body) <= limit else _whole(body, limit - 1)
    spans = []
    for entity in entities:
        tags = _tags(entity)
        if tags is not None:
            spans.append(Span(entity.offset, min(entity.offset + entity.length, cut), *tags))
    return del_surrogate(_render(body[:cut], spans)) + ("…" if cut < len(body) else "")