FILE / ScuroNeko/Laniakea
tgrich/html_render.go
Исходный файл и его история в репозитории.
(fix): runtime reliability (tests): regression coverage (doc): v1.1 release notes
549 lines
15 KiB
Go
549 lines
15 KiB
Go
package tgrich
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
|
|
)
|
|
|
|
type buildState struct {
|
|
media []tgapi.InputRichMessageMedia
|
|
}
|
|
|
|
// BuildHTML converts input rich blocks to an HTML-based rich message and
|
|
// collects media blocks into InputRichMessage.Media.
|
|
//
|
|
// Since: Bot API 10.2
|
|
func BuildHTML(blocks ...tgapi.InputRichBlock) (tgapi.InputRichMessage, error) {
|
|
return buildHTML(false, blocks...)
|
|
}
|
|
|
|
// BuildDraftHTML converts draft-compatible input rich blocks to HTML.
|
|
// Unlike BuildHTML, it permits the draft-only thinking block.
|
|
//
|
|
// Since: Bot API 10.2
|
|
func BuildDraftHTML(blocks ...tgapi.InputRichBlock) (tgapi.InputRichMessage, error) {
|
|
return buildHTML(true, blocks...)
|
|
}
|
|
|
|
func buildHTML(allowThinking bool, blocks ...tgapi.InputRichBlock) (tgapi.InputRichMessage, error) {
|
|
if err := validateRichBlocks(blocks, allowThinking); err != nil {
|
|
return tgapi.InputRichMessage{}, err
|
|
}
|
|
state := new(buildState)
|
|
rendered, err := renderBlocksHTMLState(blocks, 0, state)
|
|
if err != nil {
|
|
return tgapi.InputRichMessage{}, err
|
|
}
|
|
if allowThinking {
|
|
for _, item := range state.media {
|
|
if strings.HasPrefix(item.Media.Media, "attach://") {
|
|
return tgapi.InputRichMessage{}, tgapi.ErrRichMessageDraftUploadUnsupported
|
|
}
|
|
}
|
|
}
|
|
return tgapi.InputRichMessage{
|
|
HTML: strings.Join(rendered, ""),
|
|
Media: state.media,
|
|
}, nil
|
|
}
|
|
|
|
// ToHTML converts one input rich block to an HTML-based rich message.
|
|
//
|
|
// Since: Bot API 10.2
|
|
func ToHTML(block tgapi.InputRichBlock) (tgapi.InputRichMessage, error) { return BuildHTML(block) }
|
|
|
|
func (s *buildState) addMedia(media tgapi.InputMedia) (string, error) {
|
|
if len(s.media) >= maxRichMedia {
|
|
return "", ErrRichTooManyMedia
|
|
}
|
|
var kind string
|
|
switch media.Type {
|
|
case tgapi.InputMediaTypePhoto:
|
|
kind = "photo"
|
|
case tgapi.InputMediaTypeAnimation, tgapi.InputMediaTypeVideo:
|
|
kind = "video"
|
|
case tgapi.InputMediaTypeAudio, tgapi.InputMediaTypeVoiceNote:
|
|
kind = "audio"
|
|
default:
|
|
return "", ErrRichInvalidMedia
|
|
}
|
|
id := "media_" + strconv.Itoa(len(s.media)+1)
|
|
s.media = append(s.media, tgapi.InputRichMessageMedia{ID: id, Media: media})
|
|
return "tg://" + kind + "?id=" + id, nil
|
|
}
|
|
|
|
func renderText(t tgapi.RichText, step int) (string, error) {
|
|
switch el := t.(type) {
|
|
case tgapi.RichTextPlain:
|
|
return escapeHTML(string(el)), nil
|
|
case tgapi.RichTextArray:
|
|
var b strings.Builder
|
|
for _, item := range el {
|
|
part, err := renderText(item, step)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
b.WriteString(part)
|
|
}
|
|
return b.String(), nil
|
|
case tgapi.RichTextWrap:
|
|
s, err := renderTextChild(el.Text, step)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
tagMap := map[string]string{
|
|
"bold": "b", "italic": "i", "underline": "u", "strikethrough": "s",
|
|
"subscript": "sub", "superscript": "sup", "marked": "mark",
|
|
"spoiler": "tg-spoiler", "code": "code",
|
|
}
|
|
tag, ok := tagMap[el.Tag]
|
|
if !ok {
|
|
return "", ErrRichUnknownTag
|
|
}
|
|
return "<" + tag + ">" + s + "</" + tag + ">", nil
|
|
case tgapi.RichTextDateTime:
|
|
s, err := renderTextChild(el.Text, step)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
attrs := []string{`unix="` + strconv.FormatInt(el.UnixTime, 10) + `"`}
|
|
if el.DateTimeFormat != "" {
|
|
attrs = append(attrs, `format="`+escapeHTML(el.DateTimeFormat)+`"`)
|
|
}
|
|
return openTag("tg-time", attrs) + s + "</tg-time>", nil
|
|
case tgapi.RichTextTextMention:
|
|
s, err := renderTextChild(el.Text, step)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
attrs := []string{`href="` + "tg://user?id=" + strconv.FormatInt(el.User.ID, 10) + `"`}
|
|
return openTag("a", attrs) + s + "</a>", nil
|
|
case tgapi.RichTextCustomEmoji:
|
|
s := escapeHTML(el.AlternativeText)
|
|
attrs := formatAttrs(map[string]string{"emoji-id": el.CustomEmojiID})
|
|
return openTag("tg-emoji", attrs) + s + "</tg-emoji>", nil
|
|
case tgapi.RichTextMathematicalExpression:
|
|
s := escapeHTML(el.Expression)
|
|
return "<tg-math>" + s + "</tg-math>", nil
|
|
case tgapi.RichTextURL:
|
|
s, err := renderTextChild(el.Text, step)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
attrs := formatAttrs(map[string]string{"href": el.URL})
|
|
return openTag("a", attrs) + s + "</a>", nil
|
|
case tgapi.RichTextEmailAddress:
|
|
s, err := renderTextChild(el.Text, step)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
attrs := formatAttrs(map[string]string{"href": "mailto:" + el.EmailAddress})
|
|
return openTag("a", attrs) + s + "</a>", nil
|
|
case tgapi.RichTextPhoneNumber:
|
|
s, err := renderTextChild(el.Text, step)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
attrs := formatAttrs(map[string]string{"href": "tel:" + el.PhoneNumber})
|
|
return openTag("a", attrs) + s + "</a>", nil
|
|
case tgapi.RichTextBankCardNumber:
|
|
return renderTextChild(el.Text, step)
|
|
case tgapi.RichTextMention:
|
|
return renderTextChild(el.Text, step)
|
|
case tgapi.RichTextHashtag:
|
|
return renderTextChild(el.Text, step)
|
|
case tgapi.RichTextCashtag:
|
|
return renderTextChild(el.Text, step)
|
|
case tgapi.RichTextBotCommand:
|
|
return renderTextChild(el.Text, step)
|
|
case tgapi.RichTextAnchor:
|
|
attrs := formatAttrs(map[string]string{"name": el.Name})
|
|
return openTag("a", attrs) + "</a>", nil
|
|
case tgapi.RichTextAnchorLink:
|
|
s, err := renderTextChild(el.Text, step)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
attrs := formatAttrs(map[string]string{"href": "#" + el.AnchorName})
|
|
return openTag("a", attrs) + s + "</a>", nil
|
|
case tgapi.RichTextReference:
|
|
s, err := renderTextChild(el.Text, step)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
attrs := formatAttrs(map[string]string{"name": el.Name})
|
|
return openTag("tg-reference", attrs) + s + "</tg-reference>", nil
|
|
case tgapi.RichTextReferenceLink:
|
|
s, err := renderTextChild(el.Text, step)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
attrs := formatAttrs(map[string]string{"href": "#" + el.ReferenceName})
|
|
return openTag("a", attrs) + s + "</a>", nil
|
|
}
|
|
return "", ErrRichUnknownTag
|
|
}
|
|
|
|
func renderTextChild(t tgapi.RichText, step int) (string, error) {
|
|
if step >= maxRichDepth {
|
|
return "", ErrRichNestingTooDeep
|
|
}
|
|
return renderText(t, step+1)
|
|
}
|
|
|
|
func renderHTMLTable(t tgapi.InputRichBlockTable, step int, state *buildState) (string, error) {
|
|
attrs := map[string]string{}
|
|
if t.IsBordered {
|
|
attrs["bordered"] = ""
|
|
}
|
|
if t.IsStriped {
|
|
attrs["striped"] = ""
|
|
}
|
|
|
|
var rows strings.Builder
|
|
for _, row := range t.Cells {
|
|
r, err := renderHTMLTableRow(row, step, state)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
rows.WriteString(r)
|
|
}
|
|
caption := ""
|
|
if t.Caption != nil {
|
|
text, err := renderText(*t.Caption, step+1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
caption = "<caption>" + text + "</caption>"
|
|
}
|
|
return openTag("table", formatAttrs(attrs)) + caption + rows.String() + "</table>", nil
|
|
}
|
|
func renderHTMLTableRow(rows []tgapi.RichBlockTableCell, step int, state *buildState) (string, error) {
|
|
var out strings.Builder
|
|
for _, cell := range rows {
|
|
row, err := renderHTMLTableCell(cell, step, state)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
out.WriteString(row)
|
|
}
|
|
return "<tr>" + out.String() + "</tr>", nil
|
|
}
|
|
func renderHTMLTableCell(c tgapi.RichBlockTableCell, step int, _ *buildState) (string, error) {
|
|
var renderedText string
|
|
var err error
|
|
if c.Text != nil {
|
|
renderedText, err = renderText(c.Text, step+1)
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
mapAttrs := make(map[string]string)
|
|
tag := "td"
|
|
if c.IsHeader {
|
|
tag = "th"
|
|
}
|
|
if c.RowSpan > 0 {
|
|
mapAttrs["rowspan"] = strconv.Itoa(c.RowSpan)
|
|
}
|
|
if c.ColSpan > 0 {
|
|
mapAttrs["colspan"] = strconv.Itoa(c.ColSpan)
|
|
}
|
|
if c.Align != "" {
|
|
mapAttrs["align"] = c.Align
|
|
}
|
|
if c.VAlign != "" {
|
|
mapAttrs["valign"] = c.VAlign
|
|
}
|
|
return openTag(tag, formatAttrs(mapAttrs)) + renderedText + "</" + tag + ">", nil
|
|
}
|
|
func renderHTMLList(l tgapi.InputRichBlockList, step int, state *buildState) (string, error) {
|
|
if len(l.Items) == 0 {
|
|
return "<ul></ul>", nil
|
|
}
|
|
|
|
hasType := false
|
|
hasNoType := false
|
|
|
|
var items []string
|
|
for _, item := range l.Items {
|
|
if item.Type != "" {
|
|
hasType = true
|
|
} else {
|
|
hasNoType = true
|
|
if item.Value != 0 {
|
|
return "", ErrRichInvalidListItem
|
|
}
|
|
}
|
|
rendered, err := renderHTMLListItem(item, step, state)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
items = append(items, rendered)
|
|
}
|
|
|
|
switch {
|
|
case hasType && !hasNoType:
|
|
return "<ol>\n" + strings.Join(items, "\n") + "</ol>", nil
|
|
case hasNoType && !hasType:
|
|
return "<ul>\n" + strings.Join(items, "\n") + "</ul>", nil
|
|
}
|
|
return "", ErrRichListItemMix
|
|
}
|
|
|
|
func renderHTMLCaption(cap tgapi.RichBlockCaption, step int) (string, error) {
|
|
caption := "<figcaption>"
|
|
text, err := renderText(cap.Text, step)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
caption += text
|
|
if cap.Credit != nil {
|
|
cred, err := renderText(cap.Credit, step)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
caption += "<cite>" + cred + "</cite>"
|
|
}
|
|
caption += "</figcaption>"
|
|
return caption, nil
|
|
}
|
|
func renderHTMLListItem(i tgapi.InputRichBlockListItem, step int, state *buildState) (string, error) {
|
|
mapAttrs := map[string]string{}
|
|
|
|
renderedBlocks, err := renderBlocksHTMLState(i.Blocks, step+1, state)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
blocks := strings.Join(renderedBlocks, "")
|
|
if i.HasCheckbox {
|
|
var input string
|
|
if i.IsChecked {
|
|
input = `<input type="checkbox" checked/>`
|
|
} else {
|
|
input = `<input type="checkbox"/>`
|
|
}
|
|
blocks = input + blocks
|
|
}
|
|
if i.Value > 0 {
|
|
mapAttrs["value"] = strconv.Itoa(i.Value)
|
|
}
|
|
if i.Type != "" {
|
|
mapAttrs["type"] = string(i.Type)
|
|
}
|
|
return openTag("li", formatAttrs(mapAttrs)) + blocks + "</li>", nil
|
|
}
|
|
|
|
func renderHTMLMap(block tgapi.InputRichBlockMap, step int) (string, error) {
|
|
attrs := map[string]string{
|
|
"lat": strconv.FormatFloat(block.Location.Latitude, 'f', -1, 64),
|
|
"long": strconv.FormatFloat(block.Location.Longitude, 'f', -1, 64),
|
|
}
|
|
if block.Zoom != 0 {
|
|
attrs["zoom"] = strconv.Itoa(int(block.Zoom))
|
|
}
|
|
if block.Width != 0 {
|
|
attrs["width"] = strconv.Itoa(int(block.Width))
|
|
}
|
|
if block.Height != 0 {
|
|
attrs["height"] = strconv.Itoa(int(block.Height))
|
|
}
|
|
media := selfClosingTag("tg-map", formatAttrs(attrs))
|
|
if block.Caption == nil {
|
|
return media, nil
|
|
}
|
|
caption, err := renderHTMLCaption(*block.Caption, step+1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return "<figure>" + media + caption + "</figure>", nil
|
|
}
|
|
|
|
func renderHTMLMedia(media tgapi.InputMedia, caption *tgapi.RichBlockCaption, tag string, step int, state *buildState) (string, error) {
|
|
src, err := state.addMedia(media)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
attrs := map[string]string{"src": src}
|
|
if media.HasSpoiler != nil && *media.HasSpoiler {
|
|
attrs["tg-spoiler"] = ""
|
|
}
|
|
|
|
var element string
|
|
if tag == "img" {
|
|
element = selfClosingTag(tag, formatAttrs(attrs))
|
|
} else {
|
|
element = openTag(tag, formatAttrs(attrs)) + "</" + tag + ">"
|
|
}
|
|
if caption == nil {
|
|
return element, nil
|
|
}
|
|
renderedCaption, err := renderHTMLCaption(*caption, step+1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return "<figure>" + element + renderedCaption + "</figure>", nil
|
|
}
|
|
|
|
// func renderBlocksHTML(blocks []tgapi.InputRichBlock, step int) ([]string, error) {
|
|
// return renderBlocksHTMLState(blocks, step, new(buildState))
|
|
// }
|
|
|
|
func renderBlocksHTMLState(blocks []tgapi.InputRichBlock, step int, state *buildState) ([]string, error) {
|
|
var out []string
|
|
for _, b := range blocks {
|
|
html, err := renderBlockHTMLState(b, step, state)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, html)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func renderBlockHTML(block tgapi.InputRichBlock, step int) (string, error) {
|
|
return renderBlockHTMLState(block, step, new(buildState))
|
|
}
|
|
|
|
func renderBlockHTMLState(block tgapi.InputRichBlock, step int, state *buildState) (string, error) {
|
|
if step >= maxRichDepth {
|
|
return "", ErrRichNestingTooDeep
|
|
}
|
|
switch el := block.(type) {
|
|
case tgapi.InputRichBlockParagraph:
|
|
t, err := renderText(el.Text, step+1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return "<p>" + t + "</p>", nil
|
|
case tgapi.InputRichBlockSectionHeading:
|
|
t, err := renderText(el.Text, step+1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
size := strconv.Itoa(int(el.Size))
|
|
return "<h" + size + ">" + t + "</h" + size + ">", nil
|
|
case tgapi.InputRichBlockPreformatted:
|
|
t, err := renderText(el.Text, step+1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if el.Language != "" {
|
|
t = fmt.Sprintf(`<code class="language-%s">%s</code>`, escapeHTML(el.Language), t)
|
|
}
|
|
return "<pre>" + t + "</pre>", nil
|
|
case tgapi.InputRichBlockFooter:
|
|
t, err := renderText(el.Text, step+1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return "<footer>" + t + "</footer>", nil
|
|
case tgapi.InputRichBlockDivider:
|
|
return "<hr/>", nil
|
|
case tgapi.InputRichBlockMath:
|
|
exp := escapeHTML(el.Expression)
|
|
return "<tg-math-block>" + exp + "</tg-math-block>", nil
|
|
case tgapi.InputRichBlockAnchor:
|
|
return fmt.Sprintf(`<a name="%s"></a>`, escapeHTML(el.Name)), nil
|
|
case tgapi.InputRichBlockList:
|
|
return renderHTMLList(el, step, state)
|
|
case tgapi.InputRichBlockBlockQuotation:
|
|
content, err := renderBlocksHTMLState(el.Blocks, step+1, state)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
credit := ""
|
|
if el.Credit != nil {
|
|
credit, err = renderText(*el.Credit, step+1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
credit = "<cite>" + credit + "</cite>"
|
|
}
|
|
return "<blockquote>" + strings.Join(content, "") + credit + "</blockquote>", nil
|
|
case tgapi.InputRichBlockPullQuotation:
|
|
text, err := renderText(el.Text, step+1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
credit := ""
|
|
if el.Credit != nil {
|
|
credit, err = renderText(*el.Credit, step+1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
credit = "<cite>" + credit + "</cite>"
|
|
}
|
|
return "<aside>" + text + credit + "</aside>", nil
|
|
case tgapi.InputRichBlockCollage:
|
|
blocks, err := renderBlocksHTMLState(el.Blocks, step+1, state)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
caption := ""
|
|
if el.Caption != nil {
|
|
caption, err = renderHTMLCaption(*el.Caption, step+1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
return "<tg-collage>" + strings.Join(blocks, "") + caption + "</tg-collage>", err
|
|
case tgapi.InputRichBlockSlideshow:
|
|
blocks, err := renderBlocksHTMLState(el.Blocks, step+1, state)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
caption := ""
|
|
if el.Caption != nil {
|
|
caption, err = renderHTMLCaption(*el.Caption, step+1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
return "<tg-slideshow>" + strings.Join(blocks, "") + caption + "</tg-slideshow>", err
|
|
case tgapi.InputRichBlockTable:
|
|
return renderHTMLTable(el, step, state)
|
|
case tgapi.InputRichBlockDetails:
|
|
summary, err := renderText(el.Summary, step+1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
blocks, err := renderBlocksHTMLState(el.Blocks, step+1, state)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
attrs := make(map[string]string)
|
|
if el.IsOpen {
|
|
attrs["open"] = ""
|
|
}
|
|
|
|
return openTag("details", formatAttrs(attrs)) + "<summary>" + summary + "</summary>" + strings.Join(blocks, "") + "</details>", nil
|
|
case tgapi.InputRichBlockMap:
|
|
return renderHTMLMap(el, step)
|
|
case tgapi.InputRichBlockAnimation:
|
|
return renderHTMLMedia(el.Animation, el.Caption, "video", step, state)
|
|
case tgapi.InputRichBlockAudio:
|
|
return renderHTMLMedia(el.Audio, el.Caption, "audio", step, state)
|
|
case tgapi.InputRichBlockPhoto:
|
|
return renderHTMLMedia(el.Photo, el.Caption, "img", step, state)
|
|
case tgapi.InputRichBlockVideo:
|
|
return renderHTMLMedia(el.Video, el.Caption, "video", step, state)
|
|
case tgapi.InputRichBlockVoiceNote:
|
|
return renderHTMLMedia(el.VoiceNote, el.Caption, "audio", step, state)
|
|
case tgapi.InputRichBlockThinking:
|
|
text, err := renderText(el.Text, step+1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return "<tg-thinking>" + text + "</tg-thinking>", nil
|
|
}
|
|
return "", ErrRichUnknownTag
|
|
}
|