X-Git-Url: http://git.giacomolli.uk/onechat/blobdiff_plain/fb242538c1b012b0faf3cc4045a9b1c370f1ef42:/markdown.go..09cde69fdffe2721434392036f98dc241177f5a1:/telegram_markdown.go diff --git a/markdown.go b/telegram_markdown.go similarity index 52% rename from markdown.go rename to telegram_markdown.go index 1a2f4fe..54bfbcb 100644 --- a/markdown.go +++ b/telegram_markdown.go @@ -7,29 +7,12 @@ import ( "unicode/utf8" ) -// markdownPunctuation holds the ASCII characters that a backslash makes plain. -const markdownPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" +// telegramMarkdownPunctuation holds the ASCII characters that a backslash makes +// plain. +const telegramMarkdownPunctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" -// markdownText is the plain text of a Markdown reply and its Telegram entities, -// while the conversion builds it. Units counts the UTF-16 code units of text, -// the unit of the entity offsets and lengths. -type markdownText struct { - text strings.Builder - units int - entities []botEntity -} - -// markdownSpan is an inline mark of a line: the entity type, the content inside -// the marks, the index after the closing mark, and the URL of a link. -type markdownSpan struct { - kind string - content string - end int - url string -} - -// markdownEntities converts the Markdown of a reply to plain text and Telegram -// message entities. It knows bold (** and __), italic (* and _), +// telegramMarkdownEntities converts the Markdown of a reply to plain text and +// Telegram message entities. It knows bold (** and __), italic (* and _), // strikethrough (~~), inline code, fenced code blocks with an optional // language, links [text](url), and ATX headings, which become bold. Lists, // quotes, and tables stay as their text. A mark that does not close stays as @@ -37,8 +20,8 @@ type markdownSpan struct { // // Inline marks do not cross a line break, so a mark that does not close cannot // change the rest of the reply. -func markdownEntities(markdown string) (string, []botEntity) { - var out markdownText +func telegramMarkdownEntities(markdown string) (string, []TelegramEntity) { + var out TelegramMarkdownText lines := strings.Split(markdown, "\n") for index := 0; index < len(lines); index++ { if index > 0 { @@ -46,14 +29,14 @@ func markdownEntities(markdown string) (string, []botEntity) { } line := lines[index] - language, fence := codeFence(line) - end := closingFence(lines, index, fence) - heading := headingText(line) + language, fence := telegramCodeFence(line) + end := telegramClosingFence(lines, index, fence) + heading := telegramHeadingText(line) switch { case end > index: start := out.units out.add(strings.Join(lines[index+1:end], "\n")) - out.entity(botEntity{ + out.entity(TelegramEntity{ Type: "pre", Offset: start, Length: out.units - start, @@ -64,7 +47,7 @@ func markdownEntities(markdown string) (string, []botEntity) { case heading != "": start := out.units out.inline(heading) - out.entity(botEntity{ + out.entity(TelegramEntity{ Type: "bold", Offset: start, Length: out.units - start, @@ -74,7 +57,7 @@ func markdownEntities(markdown string) (string, []botEntity) { } } - slices.SortStableFunc(out.entities, func(a, b botEntity) int { + slices.SortStableFunc(out.entities, func(a, b TelegramEntity) int { if a.Offset != b.Offset { return a.Offset - b.Offset } @@ -85,15 +68,62 @@ func markdownEntities(markdown string) (string, []botEntity) { return out.text.String(), out.entities } +// telegramCodeFence returns the language of a line that opens a fenced code +// block, and the fence. The fence is "" when line does not open a block. +func telegramCodeFence(line string) (string, string) { + trimmed := strings.TrimLeft(line, " ") + if len(line)-len(trimmed) > 3 || !strings.HasPrefix(trimmed, "```") { + return "", "" + } + + run := len(trimmed) - len(strings.TrimLeft(trimmed, "`")) + info := strings.TrimSpace(trimmed[run:]) + if strings.Contains(info, "`") { + return "", "" + } + + language, _, _ := strings.Cut(info, " ") + + return language, trimmed[:run] +} + +// telegramClosesFence tells if line closes the code block of fence. +func telegramClosesFence(line, fence string) bool { + trimmed := strings.TrimSpace(line) + return strings.HasPrefix(trimmed, fence) && strings.Trim(trimmed, "`") == "" +} + +// telegramUTF16Units returns the number of UTF-16 code units of text. +func telegramUTF16Units(text string) int { + units := 0 + for _, char := range text { + units++ + if char > 0xFFFF { + units++ + } + } + + return units +} + +// TelegramMarkdownText is the plain text of a Markdown reply and its Telegram +// entities, while the conversion builds it. Units counts the UTF-16 code units +// of text, the unit of the entity offsets and lengths. +type TelegramMarkdownText struct { + text strings.Builder + units int + entities []TelegramEntity +} + // add appends text. -func (m *markdownText) add(text string) { +func (m *TelegramMarkdownText) add(text string) { m.text.WriteString(text) - m.units += utf16Units(text) + m.units += telegramUTF16Units(text) } // entity keeps entity, unless it is empty, because Telegram refuses an entity // of length 0. -func (m *markdownText) entity(entity botEntity) { +func (m *TelegramMarkdownText) entity(entity TelegramEntity) { if entity.Length > 0 { m.entities = append(m.entities, entity) } @@ -102,11 +132,11 @@ func (m *markdownText) entity(entity botEntity) { // inline appends one line of Markdown with its inline marks as entities. A // backslash before an ASCII punctuation character makes the character plain // text. -func (m *markdownText) inline(line string) { +func (m *TelegramMarkdownText) inline(line string) { plain := 0 index := 0 for index < len(line) { - if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(markdownPunctuation, line[index+1]) >= 0 { + if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(telegramMarkdownPunctuation, line[index+1]) >= 0 { m.add(line[plain:index]) plain = index + 1 index += 2 @@ -114,7 +144,7 @@ func (m *markdownText) inline(line string) { continue } - span, found := markSpan(line, index) + span, found := telegramMarkSpan(line, index) if !found { index++ continue @@ -128,7 +158,7 @@ func (m *markdownText) inline(line string) { m.inline(span.content) } - m.entity(botEntity{ + m.entity(TelegramEntity{ Type: span.kind, Offset: start, Length: m.units - start, @@ -142,34 +172,25 @@ func (m *markdownText) inline(line string) { m.add(line[plain:]) } -// codeFence returns the language of a line that opens a fenced code block, and -// the fence. The fence is "" when line does not open a block. -func codeFence(line string) (string, string) { - trimmed := strings.TrimLeft(line, " ") - if len(line)-len(trimmed) > 3 || !strings.HasPrefix(trimmed, "```") { - return "", "" - } - - run := len(trimmed) - len(strings.TrimLeft(trimmed, "`")) - info := strings.TrimSpace(trimmed[run:]) - if strings.Contains(info, "`") { - return "", "" - } - - language, _, _ := strings.Cut(info, " ") - - return language, trimmed[:run] +// TelegramMarkdownSpan is an inline mark of a line: the entity type, the +// content inside the marks, the index after the closing mark, and the URL of a +// link. +type TelegramMarkdownSpan struct { + kind string + content string + end int + url string } -// closingFence returns the index of the line after index that closes the code -// block of fence, or -1 when fence is "" or no line closes it. -func closingFence(lines []string, index int, fence string) int { +// telegramClosingFence returns the index of the line after index that closes +// the code block of fence, or -1 when fence is "" or no line closes it. +func telegramClosingFence(lines []string, index int, fence string) int { if fence == "" { return -1 } for end := index + 1; end < len(lines); end++ { - if closesFence(lines[end], fence) { + if telegramClosesFence(lines[end], fence) { return end } } @@ -177,15 +198,9 @@ func closingFence(lines []string, index int, fence string) int { return -1 } -// closesFence tells if line closes the code block of fence. -func closesFence(line, fence string) bool { - trimmed := strings.TrimSpace(line) - return strings.HasPrefix(trimmed, fence) && strings.Trim(trimmed, "`") == "" -} - -// headingText returns the text of an ATX heading line without its marks, or "" -// when line is not a heading or has no text. -func headingText(line string) string { +// telegramHeadingText returns the text of an ATX heading line without its +// marks, or "" when line is not a heading or has no text. +func telegramHeadingText(line string) string { trimmed := strings.TrimLeft(line, " ") if len(line)-len(trimmed) > 3 { return "" @@ -210,48 +225,36 @@ func headingText(line string) string { return rest } -// utf16Units returns the number of UTF-16 code units of text. -func utf16Units(text string) int { - units := 0 - for _, char := range text { - units++ - if char > 0xFFFF { - units++ - } - } - - return units -} - -// markSpan returns the inline mark that starts at index of line and closes in -// line. It returns false when no mark starts there or the mark does not close. -func markSpan(line string, index int) (markdownSpan, bool) { +// telegramMarkSpan returns the inline mark that starts at index of line and +// closes in line. It returns false when no mark starts there or the mark does +// not close. +func telegramMarkSpan(line string, index int) (TelegramMarkdownSpan, bool) { switch line[index] { case '`': - return codeSpan(line, index) + return telegramCodeSpan(line, index) case '[': - return linkSpan(line, index) + return telegramLinkSpan(line, index) case '~': - return delimited(line, index, "~~", "strikethrough") + return telegramDelimited(line, index, "~~", "strikethrough") case '*', '_': double := line[index : index+1] double += double - span, found := delimited(line, index, double, "bold") + span, found := telegramDelimited(line, index, double, "bold") if found { return span, true } - return delimited(line, index, line[index:index+1], "italic") + return telegramDelimited(line, index, line[index:index+1], "italic") default: - return markdownSpan{}, false + return TelegramMarkdownSpan{}, false } } -// codeSpan returns the inline code that starts at index: a run of backticks, -// the code, and a run of the same length. One space on each side of the code -// is left out, as in Markdown. -func codeSpan(line string, index int) (markdownSpan, bool) { - run := backticks(line[index:]) +// telegramCodeSpan returns the inline code that starts at index: a run of +// backticks, the code, and a run of the same length. One space on each side of +// the code is left out, as in Markdown. +func telegramCodeSpan(line string, index int) (TelegramMarkdownSpan, bool) { + run := telegramBackticks(line[index:]) for search := index + run; search < len(line); { next := strings.IndexByte(line[search:], '`') if next < 0 { @@ -259,7 +262,7 @@ func codeSpan(line string, index int) (markdownSpan, bool) { } next += search - length := backticks(line[next:]) + length := telegramBackticks(line[next:]) if length != run { search = next + length continue @@ -274,19 +277,19 @@ func codeSpan(line string, index int) (markdownSpan, bool) { content = content[1 : len(content)-1] } - return markdownSpan{ + return TelegramMarkdownSpan{ kind: "code", content: content, end: next + run, }, true } - return markdownSpan{}, false + return TelegramMarkdownSpan{}, false } -// linkSpan returns the link [text](url) that starts at index. The text can -// hold brackets in pairs, and the URL holds no space. -func linkSpan(line string, index int) (markdownSpan, bool) { +// telegramLinkSpan returns the link [text](url) that starts at index. The text +// can hold brackets in pairs, and the URL holds no space. +func telegramLinkSpan(line string, index int) (TelegramMarkdownSpan, bool) { depth := 0 for closing := index; closing < len(line); closing++ { switch line[closing] { @@ -304,15 +307,15 @@ func linkSpan(line string, index int) (markdownSpan, bool) { rest := line[closing+1:] if closing == index+1 || !strings.HasPrefix(rest, "(") { - return markdownSpan{}, false + return TelegramMarkdownSpan{}, false } url, _, found := strings.Cut(rest[1:], ")") if !found || url == "" || strings.ContainsAny(url, " \t") { - return markdownSpan{}, false + return TelegramMarkdownSpan{}, false } - return markdownSpan{ + return TelegramMarkdownSpan{ kind: "text_link", content: line[index+1 : closing], end: closing + 2 + len(url) + 1, @@ -320,27 +323,27 @@ func linkSpan(line string, index int) (markdownSpan, bool) { }, true } - return markdownSpan{}, false + return TelegramMarkdownSpan{}, false } -// delimited returns the span of kind between the mark at index and the same -// mark later in line. An opening mark is followed by a character that is not a -// space, and a closing mark follows one. A single mark is not a part of a +// telegramDelimited returns the span of kind between the mark at index and the +// same mark later in line. An opening mark is followed by a character that is +// not a space, and a closing mark follows one. A single mark is not a part of a // double one. A mark of underscores does not open or close inside a word, so // snake_case stays as text. A code span inside the content is skipped whole. -func delimited(line string, index int, mark, kind string) (markdownSpan, bool) { +func telegramDelimited(line string, index int, mark, kind string) (TelegramMarkdownSpan, bool) { char := mark[0] after := index + len(mark) - if !strings.HasPrefix(line[index:], mark) || after >= len(line) || isSpace(line[after]) { - return markdownSpan{}, false + if !strings.HasPrefix(line[index:], mark) || after >= len(line) || telegramIsSpace(line[after]) { + return TelegramMarkdownSpan{}, false } if len(mark) == 1 && (line[after] == char || index > 0 && line[index-1] == char) { - return markdownSpan{}, false + return TelegramMarkdownSpan{}, false } - if char == '_' && index > 0 && wordCharBefore(line[:index]) { - return markdownSpan{}, false + if char == '_' && index > 0 && telegramWordCharBefore(line[:index]) { + return TelegramMarkdownSpan{}, false } for closing := after + 1; closing < len(line); closing++ { @@ -349,13 +352,13 @@ func delimited(line string, index int, mark, kind string) (markdownSpan, bool) { closing++ continue case line[closing] == '`': - span, found := codeSpan(line, closing) + span, found := telegramCodeSpan(line, closing) if found { closing = span.end - 1 } continue - case !strings.HasPrefix(line[closing:], mark) || isSpace(line[closing-1]): + case !strings.HasPrefix(line[closing:], mark) || telegramIsSpace(line[closing-1]): continue } @@ -364,39 +367,41 @@ func delimited(line string, index int, mark, kind string) (markdownSpan, bool) { continue } - if char == '_' && wordChar(line[end:]) { + if char == '_' && telegramWordChar(line[end:]) { continue } - return markdownSpan{ + return TelegramMarkdownSpan{ kind: kind, content: line[after:closing], end: end, }, true } - return markdownSpan{}, false + return TelegramMarkdownSpan{}, false } -// backticks returns the length of the run of backticks at the start of text. -func backticks(text string) int { +// telegramBackticks returns the length of the run of backticks at the start of +// text. +func telegramBackticks(text string) int { return len(text) - len(strings.TrimLeft(text, "`")) } -// isSpace tells if char is a space, a tab, or a line break. -func isSpace(char byte) bool { +// telegramIsSpace tells if char is a space, a tab, or a line break. +func telegramIsSpace(char byte) bool { return char == ' ' || char == '\t' || char == '\n' || char == '\r' } -// wordCharBefore tells if the character that ends text is a letter or a digit. -func wordCharBefore(text string) bool { +// telegramWordCharBefore tells if the character that ends text is a letter or a +// digit. +func telegramWordCharBefore(text string) bool { char, _ := utf8.DecodeLastRuneInString(text) return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char)) } -// wordChar tells if the character that starts text is a letter or a digit. An -// underscore next to one is a part of a word, such as in snake_case. -func wordChar(text string) bool { +// telegramWordChar tells if the character that starts text is a letter or a +// digit. An underscore next to one is a part of a word, such as in snake_case. +func telegramWordChar(text string) bool { char, _ := utf8.DecodeRuneInString(text) return text != "" && (unicode.IsLetter(char) || unicode.IsDigit(char)) }