"""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: ("", ""), types.MessageEntityItalic: ("", ""), types.MessageEntityUnderline: ("", ""), types.MessageEntityStrike: ("", ""), types.MessageEntityCode: ("", ""), } 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'', "" if kind is types.MessageEntityMentionName: return f'', "" if kind is types.MessageEntityPre: language = html.escape(getattr(entity, "language", "") or "", quote=True) return (f'
', "
") if language else ("
", "
") if kind is types.MessageEntityBlockquote: return ("
" if getattr(entity, "collapsed", False) else "
"), "
" 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 (``abc`` 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 "")