]> git.giacomolli.uk - onechat/blob - telegram_markdown.go
Format only the Go files of the repository
[onechat] / telegram_markdown.go
1 package main
2
3 import (
4         "slices"
5         "strings"
6 )
7
8 var (
9         // telegramTagEscaper writes the characters that rich Markdown reads as HTML
10         // as HTML entities.
11         telegramTagEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;")
12
13         // telegramTagUnescaper reverses telegramTagEscaper.
14         telegramTagUnescaper = strings.NewReplacer("&amp;", "&", "&lt;", "<")
15 )
16
17 // telegramMarkdownEntities converts the Markdown of a reply to plain text and
18 // Telegram message entities. It knows bold (** and __), italic (* and _),
19 // strikethrough (~~), inline code, fenced code blocks with an optional
20 // language, links [text](url), and ATX headings, which become bold. Lists,
21 // quotes, and tables stay as their text. A mark that does not close stays as
22 // text with no entity, and code holds no entity.
23 //
24 // Inline marks do not cross a line break, so a mark that does not close cannot
25 // change the rest of the reply.
26 func telegramMarkdownEntities(markdown string) (string, []TelegramEntity) {
27         var out TelegramMarkdownText
28
29         lines := strings.Split(markdown, "\n")
30         for index := 0; index < len(lines); index++ {
31                 if index > 0 {
32                         out.add("\n")
33                 }
34
35                 line := lines[index]
36                 language, fence := markdownCodeFence(line)
37                 end := markdownClosingFence(lines, index, fence)
38                 heading := markdownHeadingText(line)
39                 switch {
40                 case end > index:
41                         start := out.units
42                         out.add(strings.Join(lines[index+1:end], "\n"))
43                         out.entity(TelegramEntity{
44                                 Type:     "pre",
45                                 Offset:   start,
46                                 Length:   out.units - start,
47                                 Language: language,
48                         })
49
50                         index = end
51                 case heading != "":
52                         start := out.units
53                         out.inline(heading)
54                         out.entity(TelegramEntity{
55                                 Type:   "bold",
56                                 Offset: start,
57                                 Length: out.units - start,
58                         })
59                 default:
60                         out.inline(line)
61                 }
62         }
63
64         slices.SortStableFunc(out.entities, func(a, b TelegramEntity) int {
65                 if a.Offset != b.Offset {
66                         return a.Offset - b.Offset
67                 }
68
69                 return b.Length - a.Length
70         })
71
72         return out.text.String(), out.entities
73 }
74
75 // telegramEscapeTags writes each & of markdown outside inline code and fenced
76 // code blocks as &amp; and each < as &lt;. Rich Markdown reads raw HTML and
77 // HTML entities in prose, and it does not read a backslash before < as an
78 // escape. With the entities, it shows a tag name and an entity that the model
79 // wrote in prose as text.
80 func telegramEscapeTags(markdown string) string {
81         return telegramRewriteProse(markdown, telegramTagEscaper.Replace)
82 }
83
84 // telegramUnescapeTags gives back the Markdown that telegramEscapeTags escaped.
85 // It is exact, because after the escape each & outside code starts &amp; or
86 // &lt;. It also works on a part of telegramSplitRich, because a part has the
87 // same code as the text that it comes from.
88 func telegramUnescapeTags(markdown string) string {
89         return telegramRewriteProse(markdown, telegramTagUnescaper.Replace)
90 }
91
92 // telegramUTF16Units returns the number of UTF-16 code units of text.
93 func telegramUTF16Units(text string) int {
94         units := 0
95         for _, char := range text {
96                 units++
97                 if char > 0xFFFF {
98                         units++
99                 }
100         }
101
102         return units
103 }
104
105 // TelegramMarkdownText is the plain text of a Markdown reply and its Telegram
106 // entities, while the conversion builds it. Units counts the UTF-16 code units
107 // of text, the unit of the entity offsets and lengths.
108 type TelegramMarkdownText struct {
109         text     strings.Builder
110         units    int
111         entities []TelegramEntity
112 }
113
114 // add appends text.
115 func (m *TelegramMarkdownText) add(text string) {
116         m.text.WriteString(text)
117
118         m.units += telegramUTF16Units(text)
119 }
120
121 // entity keeps entity, unless it is empty, because Telegram refuses an entity
122 // of length 0.
123 func (m *TelegramMarkdownText) entity(entity TelegramEntity) {
124         if entity.Length > 0 {
125                 m.entities = append(m.entities, entity)
126         }
127 }
128
129 // inline appends one line of Markdown with its inline marks as entities. A
130 // backslash before an ASCII punctuation character makes the character plain
131 // text.
132 func (m *TelegramMarkdownText) inline(line string) {
133         plain := 0
134         index := 0
135         for index < len(line) {
136                 if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(markdownPunctuation, line[index+1]) >= 0 {
137                         m.add(line[plain:index])
138
139                         plain = index + 1
140                         index += 2
141
142                         continue
143                 }
144
145                 span, found := markdownMarkSpan(line, index)
146                 if !found {
147                         index++
148                         continue
149                 }
150
151                 m.add(line[plain:index])
152
153                 start := m.units
154                 if span.kind == "code" {
155                         m.add(span.content)
156                 } else {
157                         m.inline(span.content)
158                 }
159
160                 m.entity(TelegramEntity{
161                         Type:   span.kind,
162                         Offset: start,
163                         Length: m.units - start,
164                         URL:    span.url,
165                 })
166
167                 index = span.end
168                 plain = span.end
169         }
170
171         m.add(line[plain:])
172 }
173
174 // telegramRewriteProse returns markdown with rewrite applied to each part of a
175 // line outside inline code and fenced code blocks. A code block that does not
176 // close runs to the end, as in Markdown.
177 func telegramRewriteProse(markdown string, rewrite func(string) string) string {
178         var out strings.Builder
179
180         lines := strings.Split(markdown, "\n")
181         for index := 0; index < len(lines); index++ {
182                 if index > 0 {
183                         out.WriteString("\n")
184                 }
185
186                 line := lines[index]
187                 _, fence := markdownCodeFence(line)
188                 if fence == "" {
189                         telegramRewriteLineProse(&out, line, rewrite)
190
191                         continue
192                 }
193
194                 end := markdownClosingFence(lines, index, fence)
195                 if end < 0 {
196                         end = len(lines) - 1
197                 }
198
199                 out.WriteString(strings.Join(lines[index:end+1], "\n"))
200
201                 index = end
202         }
203
204         return out.String()
205 }
206
207 // telegramRewriteLineProse writes line to out with rewrite applied to each part
208 // outside inline code. A backtick after a backslash opens no code. A run of
209 // backticks that opens no inline code stays as text whole, as in Markdown.
210 func telegramRewriteLineProse(out *strings.Builder, line string, rewrite func(string) string) {
211         prose := 0
212         for index := 0; index < len(line); {
213                 switch line[index] {
214                 case '\\':
215                         if index+1 < len(line) && strings.IndexByte(markdownPunctuation, line[index+1]) >= 0 {
216                                 index += 2
217                                 continue
218                         }
219                 case '`':
220                         end := index + markdownBackticks(line[index:])
221                         span, found := markdownCodeSpan(line, index)
222                         if found {
223                                 end = span.end
224                         }
225
226                         out.WriteString(rewrite(line[prose:index]))
227                         out.WriteString(line[index:end])
228
229                         index = end
230                         prose = end
231
232                         continue
233                 }
234
235                 index++
236         }
237
238         out.WriteString(rewrite(line[prose:]))
239 }