]> git.giacomolli.uk - onechat/blob - telegram_markdown.go
Name and order the compactor code
[onechat] / telegram_markdown.go
1 package main
2
3 import (
4         "slices"
5         "strings"
6         "unicode"
7         "unicode/utf8"
8 )
9
10 // telegramMarkdownPunctuation holds the ASCII characters that a backslash makes
11 // plain.
12 const telegramMarkdownPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
13
14 // telegramMarkdownEntities converts the Markdown of a reply to plain text and
15 // Telegram message entities. It knows bold (** and __), italic (* and _),
16 // strikethrough (~~), inline code, fenced code blocks with an optional
17 // language, links [text](url), and ATX headings, which become bold. Lists,
18 // quotes, and tables stay as their text. A mark that does not close stays as
19 // text with no entity, and code holds no entity.
20 //
21 // Inline marks do not cross a line break, so a mark that does not close cannot
22 // change the rest of the reply.
23 func telegramMarkdownEntities(markdown string) (string, []TelegramEntity) {
24         var out TelegramMarkdownText
25         lines := strings.Split(markdown, "\n")
26         for index := 0; index < len(lines); index++ {
27                 if index > 0 {
28                         out.add("\n")
29                 }
30
31                 line := lines[index]
32                 language, fence := telegramCodeFence(line)
33                 end := telegramClosingFence(lines, index, fence)
34                 heading := telegramHeadingText(line)
35                 switch {
36                 case end > index:
37                         start := out.units
38                         out.add(strings.Join(lines[index+1:end], "\n"))
39                         out.entity(TelegramEntity{
40                                 Type:     "pre",
41                                 Offset:   start,
42                                 Length:   out.units - start,
43                                 Language: language,
44                         })
45
46                         index = end
47                 case heading != "":
48                         start := out.units
49                         out.inline(heading)
50                         out.entity(TelegramEntity{
51                                 Type:   "bold",
52                                 Offset: start,
53                                 Length: out.units - start,
54                         })
55                 default:
56                         out.inline(line)
57                 }
58         }
59
60         slices.SortStableFunc(out.entities, func(a, b TelegramEntity) int {
61                 if a.Offset != b.Offset {
62                         return a.Offset - b.Offset
63                 }
64
65                 return b.Length - a.Length
66         })
67
68         return out.text.String(), out.entities
69 }
70
71 // telegramCodeFence returns the language of a line that opens a fenced code
72 // block, and the fence. The fence is "" when line does not open a block.
73 func telegramCodeFence(line string) (string, string) {
74         trimmed := strings.TrimLeft(line, " ")
75         if len(line)-len(trimmed) > 3 || !strings.HasPrefix(trimmed, "```") {
76                 return "", ""
77         }
78
79         run := len(trimmed) - len(strings.TrimLeft(trimmed, "`"))
80         info := strings.TrimSpace(trimmed[run:])
81         if strings.Contains(info, "`") {
82                 return "", ""
83         }
84
85         language, _, _ := strings.Cut(info, " ")
86
87         return language, trimmed[:run]
88 }
89
90 // telegramClosesFence tells if line closes the code block of fence.
91 func telegramClosesFence(line, fence string) bool {
92         trimmed := strings.TrimSpace(line)
93         return strings.HasPrefix(trimmed, fence) && strings.Trim(trimmed, "`") == ""
94 }
95
96 // telegramUTF16Units returns the number of UTF-16 code units of text.
97 func telegramUTF16Units(text string) int {
98         units := 0
99         for _, char := range text {
100                 units++
101                 if char > 0xFFFF {
102                         units++
103                 }
104         }
105
106         return units
107 }
108
109 // TelegramMarkdownText is the plain text of a Markdown reply and its Telegram
110 // entities, while the conversion builds it. Units counts the UTF-16 code units
111 // of text, the unit of the entity offsets and lengths.
112 type TelegramMarkdownText struct {
113         text     strings.Builder
114         units    int
115         entities []TelegramEntity
116 }
117
118 // add appends text.
119 func (m *TelegramMarkdownText) add(text string) {
120         m.text.WriteString(text)
121         m.units += telegramUTF16Units(text)
122 }
123
124 // entity keeps entity, unless it is empty, because Telegram refuses an entity
125 // of length 0.
126 func (m *TelegramMarkdownText) entity(entity TelegramEntity) {
127         if entity.Length > 0 {
128                 m.entities = append(m.entities, entity)
129         }
130 }
131
132 // inline appends one line of Markdown with its inline marks as entities. A
133 // backslash before an ASCII punctuation character makes the character plain
134 // text.
135 func (m *TelegramMarkdownText) inline(line string) {
136         plain := 0
137         index := 0
138         for index < len(line) {
139                 if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(telegramMarkdownPunctuation, line[index+1]) >= 0 {
140                         m.add(line[plain:index])
141                         plain = index + 1
142                         index += 2
143
144                         continue
145                 }
146
147                 span, found := telegramMarkSpan(line, index)
148                 if !found {
149                         index++
150                         continue
151                 }
152
153                 m.add(line[plain:index])
154                 start := m.units
155                 if span.kind == "code" {
156                         m.add(span.content)
157                 } else {
158                         m.inline(span.content)
159                 }
160
161                 m.entity(TelegramEntity{
162                         Type:   span.kind,
163                         Offset: start,
164                         Length: m.units - start,
165                         URL:    span.url,
166                 })
167
168                 index = span.end
169                 plain = span.end
170         }
171
172         m.add(line[plain:])
173 }
174
175 // TelegramMarkdownSpan is an inline mark of a line: the entity type, the
176 // content inside the marks, the index after the closing mark, and the URL of a
177 // link.
178 type TelegramMarkdownSpan struct {
179         kind    string
180         content string
181         end     int
182         url     string
183 }
184
185 // telegramClosingFence returns the index of the line after index that closes
186 // the code block of fence, or -1 when fence is "" or no line closes it.
187 func telegramClosingFence(lines []string, index int, fence string) int {
188         if fence == "" {
189                 return -1
190         }
191
192         for end := index + 1; end < len(lines); end++ {
193                 if telegramClosesFence(lines[end], fence) {
194                         return end
195                 }
196         }
197
198         return -1
199 }
200
201 // telegramHeadingText returns the text of an ATX heading line without its
202 // marks, or "" when line is not a heading or has no text.
203 func telegramHeadingText(line string) string {
204         trimmed := strings.TrimLeft(line, " ")
205         if len(line)-len(trimmed) > 3 {
206                 return ""
207         }
208
209         level := len(trimmed) - len(strings.TrimLeft(trimmed, "#"))
210         if level == 0 || level > 6 {
211                 return ""
212         }
213
214         rest := trimmed[level:]
215         if rest != "" && rest[0] != ' ' && rest[0] != '\t' {
216                 return ""
217         }
218
219         rest = strings.TrimSpace(rest)
220         closed := strings.TrimRight(rest, "#")
221         if closed == "" || strings.HasSuffix(closed, " ") {
222                 rest = strings.TrimSpace(closed)
223         }
224
225         return rest
226 }
227
228 // telegramMarkSpan returns the inline mark that starts at index of line and
229 // closes in line. It returns false when no mark starts there or the mark does
230 // not close.
231 func telegramMarkSpan(line string, index int) (TelegramMarkdownSpan, bool) {
232         switch line[index] {
233         case '`':
234                 return telegramCodeSpan(line, index)
235         case '[':
236                 return telegramLinkSpan(line, index)
237         case '~':
238                 return telegramDelimited(line, index, "~~", "strikethrough")
239         case '*', '_':
240                 double := line[index : index+1]
241                 double += double
242                 span, found := telegramDelimited(line, index, double, "bold")
243                 if found {
244                         return span, true
245                 }
246
247                 return telegramDelimited(line, index, line[index:index+1], "italic")
248         default:
249                 return TelegramMarkdownSpan{}, false
250         }
251 }
252
253 // telegramCodeSpan returns the inline code that starts at index: a run of
254 // backticks, the code, and a run of the same length. One space on each side of
255 // the code is left out, as in Markdown.
256 func telegramCodeSpan(line string, index int) (TelegramMarkdownSpan, bool) {
257         run := telegramBackticks(line[index:])
258         for search := index + run; search < len(line); {
259                 next := strings.IndexByte(line[search:], '`')
260                 if next < 0 {
261                         break
262                 }
263
264                 next += search
265                 length := telegramBackticks(line[next:])
266                 if length != run {
267                         search = next + length
268                         continue
269                 }
270
271                 content := line[index+run : next]
272                 if strings.TrimSpace(content) == "" {
273                         break
274                 }
275
276                 if len(content) > 1 && content[0] == ' ' && content[len(content)-1] == ' ' {
277                         content = content[1 : len(content)-1]
278                 }
279
280                 return TelegramMarkdownSpan{
281                         kind:    "code",
282                         content: content,
283                         end:     next + run,
284                 }, true
285         }
286
287         return TelegramMarkdownSpan{}, false
288 }
289
290 // telegramLinkSpan returns the link [text](url) that starts at index. The text
291 // can hold brackets in pairs, and the URL holds no space.
292 func telegramLinkSpan(line string, index int) (TelegramMarkdownSpan, bool) {
293         depth := 0
294         for closing := index; closing < len(line); closing++ {
295                 switch line[closing] {
296                 case '\\':
297                         closing++
298                 case '[':
299                         depth++
300                 case ']':
301                         depth--
302                 }
303
304                 if depth > 0 {
305                         continue
306                 }
307
308                 rest := line[closing+1:]
309                 if closing == index+1 || !strings.HasPrefix(rest, "(") {
310                         return TelegramMarkdownSpan{}, false
311                 }
312
313                 url, _, found := strings.Cut(rest[1:], ")")
314                 if !found || url == "" || strings.ContainsAny(url, " \t") {
315                         return TelegramMarkdownSpan{}, false
316                 }
317
318                 return TelegramMarkdownSpan{
319                         kind:    "text_link",
320                         content: line[index+1 : closing],
321                         end:     closing + 2 + len(url) + 1,
322                         url:     url,
323                 }, true
324         }
325
326         return TelegramMarkdownSpan{}, false
327 }
328
329 // telegramDelimited returns the span of kind between the mark at index and the
330 // same mark later in line. An opening mark is followed by a character that is
331 // not a space, and a closing mark follows one. A single mark is not a part of a
332 // double one. A mark of underscores does not open or close inside a word, so
333 // snake_case stays as text. A code span inside the content is skipped whole.
334 func telegramDelimited(line string, index int, mark, kind string) (TelegramMarkdownSpan, bool) {
335         char := mark[0]
336         after := index + len(mark)
337         if !strings.HasPrefix(line[index:], mark) || after >= len(line) || telegramIsSpace(line[after]) {
338                 return TelegramMarkdownSpan{}, false
339         }
340
341         if len(mark) == 1 && (line[after] == char || index > 0 && line[index-1] == char) {
342                 return TelegramMarkdownSpan{}, false
343         }
344
345         if char == '_' && index > 0 && telegramWordCharBefore(line[:index]) {
346                 return TelegramMarkdownSpan{}, false
347         }
348
349         for closing := after + 1; closing < len(line); closing++ {
350                 switch {
351                 case line[closing] == '\\':
352                         closing++
353                         continue
354                 case line[closing] == '`':
355                         span, found := telegramCodeSpan(line, closing)
356                         if found {
357                                 closing = span.end - 1
358                         }
359
360                         continue
361                 case !strings.HasPrefix(line[closing:], mark) || telegramIsSpace(line[closing-1]):
362                         continue
363                 }
364
365                 end := closing + len(mark)
366                 if len(mark) == 1 && (line[closing-1] == char || end < len(line) && line[end] == char) {
367                         continue
368                 }
369
370                 if char == '_' && telegramWordChar(line[end:]) {
371                         continue
372                 }
373
374                 return TelegramMarkdownSpan{
375                         kind:    kind,
376                         content: line[after:closing],
377                         end:     end,
378                 }, true
379         }
380
381         return TelegramMarkdownSpan{}, false
382 }
383
384 // telegramBackticks returns the length of the run of backticks at the start of
385 // text.
386 func telegramBackticks(text string) int {
387         return len(text) - len(strings.TrimLeft(text, "`"))
388 }
389
390 // telegramIsSpace tells if char is a space, a tab, or a line break.
391 func telegramIsSpace(char byte) bool {
392         return char == ' ' || char == '\t' || char == '\n' || char == '\r'
393 }
394
395 // telegramWordCharBefore tells if the character that ends text is a letter or a
396 // digit.
397 func telegramWordCharBefore(text string) bool {
398         char, _ := utf8.DecodeLastRuneInString(text)
399         return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
400 }
401
402 // telegramWordChar tells if the character that starts text is a letter or a
403 // digit. An underscore next to one is a part of a word, such as in snake_case.
404 func telegramWordChar(text string) bool {
405         char, _ := utf8.DecodeRuneInString(text)
406         return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char))
407 }