]> git.giacomolli.uk - onechat/blob - telegram_markdown.go
Separate multiline Go statements
[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
10 // HTML as HTML entities.
11 telegramTagEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;")
12
13 // telegramTagUnescaper reverses telegramTagEscaper.
14 telegramTagUnescaper = strings.NewReplacer("&amp;", "&", "&lt;", "<")
15 )
16
17 // TelegramMarkdownText is the plain text of a Markdown reply and its Telegram
18 // entities, while the conversion builds it. Units counts the UTF-16 code units
19 // of text, the unit of the entity offsets and lengths.
20 type TelegramMarkdownText struct {
21 text strings.Builder
22 units int
23 entities []TelegramEntity
24 }
25
26 // add appends text.
27 func (m *TelegramMarkdownText) add(text string) {
28 m.text.WriteString(text)
29
30 m.units += telegramUTF16Units(text)
31 }
32
33 // entity keeps entity, unless it is empty, because Telegram refuses an entity
34 // of length 0.
35 func (m *TelegramMarkdownText) entity(entity TelegramEntity) {
36 if entity.Length > 0 {
37 m.entities = append(m.entities, entity)
38 }
39 }
40
41 // inline appends one line of Markdown with its inline marks as entities. A
42 // backslash before an ASCII punctuation character makes the character plain
43 // text.
44 func (m *TelegramMarkdownText) inline(line string) {
45 plain := 0
46 index := 0
47 for index < len(line) {
48 if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(markdownPunctuation, line[index+1]) >= 0 {
49 m.add(line[plain:index])
50
51 plain = index + 1
52 index += 2
53
54 continue
55 }
56
57 span, found := markdownMarkSpan(line, index)
58 if !found {
59 index++
60 continue
61 }
62
63 m.add(line[plain:index])
64
65 start := m.units
66 if span.kind == "code" {
67 m.add(span.content)
68 } else {
69 m.inline(span.content)
70 }
71
72 m.entity(TelegramEntity{
73 Type: span.kind,
74 Offset: start,
75 Length: m.units - start,
76 URL: span.url,
77 })
78
79 index = span.end
80 plain = span.end
81 }
82
83 m.add(line[plain:])
84 }
85
86 // telegramMarkdownEntities converts the Markdown of a reply to plain text and
87 // Telegram message entities. It knows bold (** and __), italic (* and _),
88 // strikethrough (~~), inline code, fenced code blocks with an optional
89 // language, links [text](url), and ATX headings, which become bold. Lists,
90 // quotes, and tables stay as their text. A mark that does not close stays as
91 // text with no entity, and code holds no entity.
92 //
93 // Inline marks do not cross a line break, so a mark that does not close cannot
94 // change the rest of the reply.
95 func telegramMarkdownEntities(markdown string) (string, []TelegramEntity) {
96 var out TelegramMarkdownText
97
98 lines := strings.Split(markdown, "\n")
99 for index := 0; index < len(lines); index++ {
100 if index > 0 {
101 out.add("\n")
102 }
103
104 line := lines[index]
105 language, fence := markdownCodeFence(line)
106 end := markdownClosingFence(lines, index, fence)
107 heading := markdownHeadingText(line)
108 switch {
109 case end > index:
110 start := out.units
111 out.add(strings.Join(lines[index+1:end], "\n"))
112 out.entity(TelegramEntity{
113 Type: "pre",
114 Offset: start,
115 Length: out.units - start,
116 Language: language,
117 })
118
119 index = end
120 case heading != "":
121 start := out.units
122 out.inline(heading)
123 out.entity(TelegramEntity{
124 Type: "bold",
125 Offset: start,
126 Length: out.units - start,
127 })
128 default:
129 out.inline(line)
130 }
131 }
132
133 slices.SortStableFunc(out.entities, func(a, b TelegramEntity) int {
134 if a.Offset != b.Offset {
135 return a.Offset - b.Offset
136 }
137
138 return b.Length - a.Length
139 })
140
141 return out.text.String(), out.entities
142 }
143
144 // telegramEscapeTags writes each & of markdown outside inline code and fenced
145 // code blocks as &amp; and each < as &lt;. Rich Markdown reads raw HTML and
146 // HTML entities in prose, and it does not read a backslash before < as an
147 // escape. With the entities, it shows a tag name and an entity that the model
148 // wrote in prose as text.
149 func telegramEscapeTags(markdown string) string {
150 return telegramRewriteProse(markdown, telegramTagEscaper.Replace)
151 }
152
153 // telegramUnescapeTags gives back the Markdown that telegramEscapeTags escaped.
154 // It is exact, because after the escape each & outside code starts &amp; or
155 // &lt;. It also works on a part of telegramSplitRich, because a part has the
156 // same code as the text that it comes from.
157 func telegramUnescapeTags(markdown string) string {
158 return telegramRewriteProse(markdown, telegramTagUnescaper.Replace)
159 }
160
161 // telegramUTF16Units returns the number of UTF-16 code units of text.
162 func telegramUTF16Units(text string) int {
163 units := 0
164 for _, char := range text {
165 units++
166 if char > 0xFFFF {
167 units++
168 }
169 }
170
171 return units
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 }