"""Telegram-style HTML -> (text, entities), for responses of commands that
run on the device (server responses arrive parsed). Offsets are UTF-16 code
units, as Telegram counts them. Pure Python: tested offline.
Tags: b strong i em u ins s strike del code pre (``
inside), a href (tg://user?id=N mentions), blockquote [expandable],
tg-spoiler / span class="tg-spoiler", tg-emoji emoji-id, br.
"""
from html.parser import HTMLParser
_SIMPLE = {
"b": "bold",
"strong": "bold",
"i": "italic",
"em": "italic",
"u": "underline",
"ins": "underline",
"s": "strike",
"strike": "strike",
"del": "strike",
"code": "code",
"pre": "pre",
"blockquote": "blockquote",
"tg-spoiler": "spoiler",
}
def utf16_len(text):
return len(text.encode("utf-16-le")) // 2
class _Parser(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.parts = []
self.length = 0 # UTF-16 units so far
self.open = [] # [(tag, entity or None)]
self.entities = []
def _entity(self, tag, attrs):
attrs = dict(attrs)
if tag == "code" and any(t == "pre" for t, _ in self.open):
pre = next(e for t, e in reversed(self.open) if t == "pre")
language = (attrs.get("class") or "").removeprefix("language-")
if pre is not None and language:
pre["language"] = language
return None
if tag == "span" and attrs.get("class") == "tg-spoiler":
return {"type": "spoiler"}
if tag in _SIMPLE:
entity = {"type": _SIMPLE[tag]}
if tag == "blockquote" and "expandable" in attrs:
entity["collapsed"] = True
return entity
if tag == "a" and attrs.get("href"):
href = attrs["href"]
if href.startswith("tg://user?id="):
try:
return {"type": "mention_name", "user_id": int(href.split("=", 1)[1])}
except ValueError:
pass
return {"type": "text_link", "url": href}
if tag == "tg-emoji":
try:
return {"type": "custom_emoji", "document_id": int(attrs.get("emoji-id"))}
except (TypeError, ValueError):
return None
return None
def handle_starttag(self, tag, attrs):
if tag == "br":
self.handle_data("\n")
return
entity = self._entity(tag, attrs)
if entity is not None:
entity["offset"] = self.length
self.open.append((tag, entity))
def handle_endtag(self, tag):
for index in range(len(self.open) - 1, -1, -1):
if self.open[index][0] == tag:
_, entity = self.open.pop(index)
if entity is not None:
entity["length"] = self.length - entity["offset"]
if entity["length"] > 0:
self.entities.append(entity)
return
def handle_data(self, data):
self.parts.append(data)
self.length += utf16_len(data)
def parse(html):
"""(plain text, entities) of `html`; entities ordered by offset."""
parser = _Parser()
parser.feed(html or "")
parser.close()
entities = sorted(parser.entities, key=lambda e: (e["offset"], -e["length"]))
return "".join(parser.parts), entities
def escape(text):
"""`text` made safe to put into a response."""
return str(text).replace("&", "&").replace("<", "<").replace(">", ">")