latexgrambot: inline LaTeX bot for Telegram in Go

Renders LaTeX expressions into PNG pictures, PDF documents and native
Telegram rich text messages, entirely locally with TeX Live and poppler.

- Chat mode with /image, /pdf, /rich plus per-user preamble and DPI
- Inline mode delivering picture, PDF and rich text results
- Broad default package set preloaded into a TeX format at image build
- MongoDB persistence: settings, users, groups, render statistics
- Private S3-compatible bucket with short-lived presigned URLs
- OpenTelemetry traces over OTLP
- Kubernetes manifests and Argo CD application, Docker Compose, GitHub
  Actions CI, BSD 3-Clause license
This commit is contained in:
corrado.mulas
2026-10-04 03:38:08 +02:00
commit 98a3614d58
29 changed files with 5140 additions and 0 deletions

742
internal/rich/latex.go Normal file
View File

@@ -0,0 +1,742 @@
package rich
import (
"strings"
"unicode"
)
// textCommands marks a document as text-mode LaTeX (as opposed to a bare
// math expression) when any of these is present.
var textCommands = map[string]bool{
"textbf": true, "textit": true, "emph": true, "underline": true,
"uline": true, "texttt": true, "textsuperscript": true,
"textsubscript": true, "textrm": true, "textsf": true, "textnormal": true,
"textup": true, "textmd": true, "textcolor": true,
"href": true, "url": true, "hyperlink": true, "hypertarget": true,
"section": true, "subsection": true,
"subsubsection": true, "item": true, "maketitle": true, "title": true,
"author": true, "documentclass": true, "par": true,
}
// wrapperEnvironments are environments whose markers are dropped from text
// mode; their content is rendered inline.
var wrapperEnvironments = map[string]bool{
"itemize": true, "enumerate": true, "center": true, "flushleft": true,
"flushright": true, "quote": true, "quotation": true, "description": true,
"small": true, "footnotesize": true, "large": true, "Large": true,
"document": true,
}
// documentClassEnvironments are math environments that keep a \begin{...}
// source in math mode.
var mathEnvironments = map[string]bool{
"equation": true, "equation*": true, "align": true, "align*": true,
"aligned": true, "gather": true, "gather*": true, "multline": true,
"multline*": true, "eqnarray": true, "eqnarray*": true, "array": true,
"matrix": true, "pmatrix": true, "bmatrix": true, "vmatrix": true,
"Vmatrix": true, "Bmatrix": true, "cases": true, "split": true,
"displaymath": true, "math": true,
}
// symbolCommands maps exotic symbol commands that client math engines
// commonly lack to Unicode. Standard LaTeX/AMS math commands (\int, \sum,
// Greek letters, arrows, relations, ...) are deliberately NOT translated:
// the client engine renders them natively, while Unicode replacements are
// not drawn by every math font.
var symbolCommands = map[string]string{
"heartsuit": "♡", "diamondsuit": "♢", "spadesuit": "♠", "clubsuit": "♣",
"checkmark": "✓", "textbullet": "•", "degree": "°", "celsius": "°C",
"copyright": "©", "registered": "®", "circledR": "®", "pounds": "£",
"euro": "€", "yen": "¥", "male": "♂", "female": "♀",
"flat": "♭", "natural": "♮", "sharp": "♯",
"Box": "□", "square": "□", "blacksquare": "■",
"triangle": "△", "blacktriangle": "▲", "blacktriangledown": "▼",
"bigstar": "★", "lozenge": "◊", "blacklozenge": "⧫",
"measuredangle": "∡", "sphericalangle": "∢",
"therefore": "∴", "because": "∵", "maltese": "✠",
"diagup": "╱", "diagdown": "╲", "complement": "∁", "eth": "ð",
"Finv": "Ⅎ", "Game": "⅁", "circledS": "Ⓢ", "mho": "℧",
}
// translateSymbols replaces known symbol commands with Unicode. Font
// commands such as \mathbb are left untouched: client math engines render
// them themselves and their Unicode math-alphanumeric fallbacks are not
// supported everywhere.
func translateSymbols(source string) string {
if !strings.Contains(source, `\`) {
return source
}
var out strings.Builder
for index := 0; index < len(source); {
if source[index] != '\\' {
out.WriteByte(source[index])
index++
continue
}
name, next := readCommand(source, index)
if name == "" {
out.WriteString(source[index:next])
index = next
continue
}
if replacement, ok := symbolCommands[name]; ok {
out.WriteString(replacement)
index = skipEmptyGroup(source, next)
continue
}
out.WriteString(source[index:next])
index = next
}
return out.String()
}
// Document converts a LaTeX source into a rich message. Math segments
// ($...$, $$...$$, \(...\), \[...\]) become math nodes; text paragraphs keep
// their line structure and map the common text-mode formatting commands to
// rich text nodes. With defaultMath, a source with no math delimiters and
// no known text-mode command is treated as a single math expression;
// otherwise such a source is parsed as text and formulas need delimiters.
func Document(source string, defaultMath bool) *Message {
source = documentBody(source)
source = translateSymbols(source)
trimmed := strings.TrimSpace(source)
if trimmed == "" {
return &Message{Blocks: []Block{{Type: "paragraph", Text: " "}}}
}
if defaultMath && isPureMath(trimmed) {
if isSymbolsOnly(trimmed) {
// Client math fonts often lack exotic symbols; the regular
// text font renders them everywhere.
return &Message{Blocks: []Block{{Type: "paragraph", Text: trimmed}}}
}
return &Message{Blocks: []Block{{Type: "mathematical_expression", Expression: trimmed}}}
}
parser := &parser{src: source}
blocks := parser.parseBlocks()
if len(blocks) == 0 {
blocks = []Block{{Type: "paragraph", Text: " "}}
}
return &Message{Blocks: blocks}
}
// IsMathExpression reports whether the source is a math expression: bare
// math commands with no text-mode command, a delimited math segment, or a
// math environment. Used to decide whether a failed compile may be retried
// with the expression forced into math mode.
func IsMathExpression(source string) bool {
trimmed := strings.TrimSpace(source)
if trimmed == "" {
return false
}
if stripOuterMathDelimiters(trimmed) != trimmed {
return true
}
// Delimiters somewhere inside mean mixed text and math: not entirely
// math, so no retry.
if strings.Contains(trimmed, "$") || strings.Contains(trimmed, `\(`) || strings.Contains(trimmed, `\[`) {
return false
}
for _, command := range commandsIn(trimmed) {
if textCommands[command] {
return false
}
}
for _, environment := range environmentsIn(trimmed) {
if !mathEnvironments[environment] {
return false
}
}
return true
}
// ContainsMathEnvironment reports whether the source contains a math
// environment such as align or matrix; those compile standalone and must
// not be wrapped again.
func ContainsMathEnvironment(source string) bool {
for _, environment := range environmentsIn(source) {
if mathEnvironments[environment] {
return true
}
}
return false
}
// stripOuterMathDelimiters removes matching outer math delimiters.
func stripOuterMathDelimiters(expr string) string {
for _, pair := range [][2]string{{`$$`, `$$`}, {`$`, `$`}, {`\[`, `\]`}, {`\(`, `\)`}} {
if len(expr) >= len(pair[0])+len(pair[1]) &&
strings.HasPrefix(expr, pair[0]) && strings.HasSuffix(expr, pair[1]) {
return strings.TrimSpace(expr[len(pair[0]) : len(expr)-len(pair[1])])
}
}
return expr
}
// isSymbolsOnly reports whether the source consists only of symbols and
// punctuation: no letters, digits or LaTeX math structure.
func isSymbolsOnly(source string) bool {
for _, r := range source {
switch r {
case '\\', '_', '{', '}', '$', '&', '#', '^':
return false
}
if r < 128 && !unicode.IsPunct(r) && !unicode.IsSpace(r) {
return false
}
}
return true
}
// InlineDocument is Document prepared for inline answers: a leading math
// expression renders inline one space after the bot mention instead of as a
// display block, and a leading paragraph starts with one space.
func InlineDocument(source string, defaultMath bool) *Message {
message := Document(source, defaultMath)
if len(message.Blocks) == 0 {
return message
}
first := &message.Blocks[0]
switch first.Type {
case "mathematical_expression":
*first = Block{Type: "paragraph", Text: []any{
" ",
Inline{Type: "mathematical_expression", Expression: first.Expression},
}}
case "paragraph":
first.Text = prependSpace(first.Text)
}
return message
}
func prependSpace(text any) any {
switch value := text.(type) {
case string:
return " " + value
case []any:
return append([]any{" "}, value...)
default:
return text
}
}
// documentBody reduces a full document to the content of its document
// environment.
func documentBody(source string) string {
const begin = `\begin{document}`
const end = `\end{document}`
index := strings.Index(source, begin)
if index < 0 {
return source
}
source = source[index+len(begin):]
if index = strings.Index(source, end); index >= 0 {
source = source[:index]
}
return source
}
// isPureMath reports whether the source should be rendered as one math
// expression: no math delimiters, no known text-mode command and no
// non-math environment.
func isPureMath(source string) bool {
if strings.Contains(source, "$") || strings.Contains(source, `\(`) || strings.Contains(source, `\[`) {
return false
}
for _, command := range commandsIn(source) {
if textCommands[command] {
return false
}
}
for _, env := range environmentsIn(source) {
if !mathEnvironments[env] {
return false
}
}
return true
}
func commandsIn(source string) []string {
var commands []string
for i := 0; i < len(source); i++ {
if source[i] != '\\' || i+1 >= len(source) {
continue
}
if source[i+1] == '\\' {
commands = append(commands, `\\`)
i++
continue
}
name := ""
next := i + 1
for next < len(source) && isCommandLetter(source[next]) {
name += string(source[next])
next++
}
if name != "" {
commands = append(commands, name)
i = next - 1
}
}
return commands
}
func environmentsIn(source string) []string {
var envs []string
for index := 0; index < len(source); {
position := strings.Index(source[index:], `\begin{`)
if position < 0 {
break
}
position += index
start := position + len(`\begin{`)
end := strings.IndexByte(source[start:], '}')
if end < 0 {
break
}
envs = append(envs, source[start:start+end])
index = start
}
return envs
}
// readEnvironment reads \begin{name} or \end{name} at index and returns the
// environment name and the position after the closing brace.
func readEnvironment(source string, index int) (string, int, bool) {
prefix := `\begin{`
if strings.HasPrefix(source[index:], `\end{`) {
prefix = `\end{`
} else if !strings.HasPrefix(source[index:], prefix) {
return "", index, false
}
start := index + len(prefix)
end := strings.IndexByte(source[start:], '}')
if end < 0 {
return "", index, false
}
return source[start : start+end], start + end + 1, true
}
func isCommandLetter(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}
type parser struct {
src string
pos int
}
func (p *parser) eof() bool { return p.pos >= len(p.src) }
func (p *parser) startsWith(prefix string) bool {
return strings.HasPrefix(p.src[p.pos:], prefix)
}
func (p *parser) parseBlocks() []Block {
var blocks []Block
var text strings.Builder
flush := func() {
trimmed := strings.TrimSpace(text.String())
text.Reset()
if trimmed == "" {
return
}
blocks = append(blocks, Block{Type: "paragraph", Text: parseInline(trimmed)})
}
for !p.eof() {
switch {
case p.startsWith(`\[`):
flush()
p.pos += 2
expression := strings.TrimSpace(p.readUntil(`\]`))
if isSymbolsOnly(expression) {
blocks = append(blocks, Block{Type: "paragraph", Text: strings.TrimSpace(expression)})
} else {
blocks = append(blocks, Block{Type: "mathematical_expression", Expression: expression})
}
case p.startsWith("$$"):
flush()
p.pos += 2
expression := strings.TrimSpace(p.readUntil("$$"))
if isSymbolsOnly(expression) {
blocks = append(blocks, Block{Type: "paragraph", Text: strings.TrimSpace(expression)})
} else {
blocks = append(blocks, Block{Type: "mathematical_expression", Expression: expression})
}
case p.startsWith(`\section`):
flush()
p.pos += len(`\section`)
if title, ok := p.readOptionalStarAndGroup(); ok {
blocks = append(blocks, Block{Type: "heading", Text: parseInline(title), Size: 2})
}
case p.startsWith(`\subsection`):
flush()
p.pos += len(`\subsection`)
if title, ok := p.readOptionalStarAndGroup(); ok {
blocks = append(blocks, Block{Type: "heading", Text: parseInline(title), Size: 3})
}
case p.startsWith(`\subsubsection`):
flush()
p.pos += len(`\subsubsection`)
if title, ok := p.readOptionalStarAndGroup(); ok {
blocks = append(blocks, Block{Type: "heading", Text: parseInline(title), Size: 4})
}
case p.startsWith(`\item`):
flush()
text.WriteString("• ")
p.pos += len(`\item`)
for p.pos < len(p.src) && (p.src[p.pos] == ' ' || p.src[p.pos] == '\t') {
p.pos++
}
case p.startsWith(`\begin{`), p.startsWith(`\end{`):
environment, after, ok := readEnvironment(p.src, p.pos)
if ok && wrapperEnvironments[environment] {
flush()
p.pos = after
} else {
text.WriteByte(p.src[p.pos])
p.pos++
}
case p.startsWith(`\par`), p.startsWith(`\\`):
flush()
if p.startsWith(`\par`) {
p.pos += len(`\par`)
} else {
p.pos += 2
}
case p.src[p.pos] == '\n' && p.blankLineAhead():
flush()
for !p.eof() && (p.src[p.pos] == '\n' || p.src[p.pos] == ' ' || p.src[p.pos] == '\t' || p.src[p.pos] == '\r') {
p.pos++
}
default:
text.WriteByte(p.src[p.pos])
p.pos++
}
}
flush()
return blocks
}
// blankLineAhead reports whether the newline at the current position is
// followed by a blank line (two or more newlines, spaces allowed between).
func (p *parser) blankLineAhead() bool {
index := p.pos + 1
if index < len(p.src) && p.src[index] == '\r' {
index++
}
for index < len(p.src) && (p.src[index] == ' ' || p.src[index] == '\t') {
index++
}
return index < len(p.src) && p.src[index] == '\n'
}
func (p *parser) readUntil(terminator string) string {
index := strings.Index(p.src[p.pos:], terminator)
if index < 0 {
result := p.src[p.pos:]
p.pos = len(p.src)
return result
}
result := p.src[p.pos : p.pos+index]
p.pos += index + len(terminator)
return result
}
// readOptionalStarAndGroup consumes an optional star and the following
// {group}; spaces are allowed before the brace.
func (p *parser) readOptionalStarAndGroup() (string, bool) {
if p.pos < len(p.src) && p.src[p.pos] == '*' {
p.pos++
}
return p.readGroup()
}
// readGroup reads a balanced {group} at the current position, skipping
// leading spaces.
func (p *parser) readGroup() (string, bool) {
for p.pos < len(p.src) && (p.src[p.pos] == ' ' || p.src[p.pos] == '\t') {
p.pos++
}
if p.pos >= len(p.src) || p.src[p.pos] != '{' {
return "", false
}
depth := 0
start := p.pos + 1
for index := p.pos; index < len(p.src); index++ {
switch p.src[index] {
case '\\':
index++ // skip the escaped character
case '{':
depth++
case '}':
depth--
if depth == 0 {
result := p.src[start:index]
p.pos = index + 1
return result, true
}
}
}
return "", false
}
// parseInline converts a text-mode fragment into a RichText value: a plain
// string when nothing needs formatting, or a slice of strings and inline
// nodes otherwise.
func parseInline(source string) any {
var parts []any
var text strings.Builder
flush := func() {
if text.Len() > 0 {
parts = append(parts, text.String())
text.Reset()
}
}
appendParsed := func(value string) {
flush()
switch parsed := parseInline(value).(type) {
case string:
if parsed != "" {
parts = append(parts, parsed)
}
case []any:
parts = append(parts, parsed...)
}
}
for index := 0; index < len(source); {
char := source[index]
switch {
case char == '\\':
if index+1 >= len(source) {
text.WriteByte(char)
index++
continue
}
switch source[index+1] {
case '&', '%', '$', '#', '_', '{', '}':
text.WriteByte(source[index+1])
index += 2
continue
case '\\':
text.WriteByte('\n')
index += 2
continue
case '(':
end := strings.Index(source[index+2:], `\)`)
if end >= 0 {
expression := source[index+2 : index+2+end]
if isSymbolsOnly(expression) {
text.WriteString(strings.TrimSpace(expression))
} else {
flush()
parts = append(parts, Inline{Type: "mathematical_expression", Expression: expression})
}
index += 2 + end + 2
continue
}
}
name, next := readCommand(source, index)
if name == "" {
text.WriteByte(char)
index++
continue
}
arg, after, hasArg := readGroupAt(source, next)
switch name {
case "textbf":
if hasArg {
flush()
parts = append(parts, Inline{Type: "bold", Text: parseInline(arg)})
index = after
continue
}
case "textit", "emph":
if hasArg {
flush()
parts = append(parts, Inline{Type: "italic", Text: parseInline(arg)})
index = after
continue
}
case "underline", "uline":
if hasArg {
flush()
parts = append(parts, Inline{Type: "underline", Text: parseInline(arg)})
index = after
continue
}
case "texttt":
if hasArg {
flush()
parts = append(parts, Inline{Type: "code", Text: parseInline(arg)})
index = after
continue
}
case "textsuperscript":
if hasArg {
flush()
parts = append(parts, Inline{Type: "superscript", Text: parseInline(arg)})
index = after
continue
}
case "textsubscript":
if hasArg {
flush()
parts = append(parts, Inline{Type: "subscript", Text: parseInline(arg)})
index = after
continue
}
case "textrm", "textsf", "textnormal", "textup", "textmd", "text":
if hasArg {
appendParsed(arg)
index = after
continue
}
case "textcolor":
if _, afterColor, ok := readGroupAt(source, next); ok {
if value, afterValue, ok := readGroupAt(source, afterColor); ok {
appendParsed(value)
index = afterValue
continue
}
}
case "href":
if url, afterURL, ok := readGroupAt(source, next); ok {
if label, afterLabel, ok := readGroupAt(source, afterURL); ok {
flush()
parts = append(parts, Inline{Type: "url", Text: parseInline(label), URL: url})
index = afterLabel
continue
}
}
case "hyperlink", "hypertarget":
// Telegram rich text has no named-anchor equivalent. Keep
// the visible label and discard the internal PDF target; the
// PNG/PDF renderer preserves the actual hyperref command.
if _, afterTarget, ok := readGroupAt(source, next); ok {
if label, afterLabel, ok := readGroupAt(source, afterTarget); ok {
appendParsed(label)
index = afterLabel
continue
}
}
case "url":
if hasArg {
flush()
parts = append(parts, Inline{Type: "url", Text: arg, URL: arg})
index = after
continue
}
case "item":
text.WriteString("• ")
index = skipEmptyGroup(source, next)
continue
case "quad", "qquad", ",", ";", ":", "!", " ":
text.WriteByte(' ')
index = skipEmptyGroup(source, next)
continue
case "ldots", "dots":
text.WriteString("…")
index = skipEmptyGroup(source, next)
continue
case "LaTeX":
text.WriteString("LaTeX")
index = skipEmptyGroup(source, next)
continue
case "TeX":
text.WriteString("TeX")
index = skipEmptyGroup(source, next)
continue
}
text.WriteString(`\` + name)
index = next
case char == '$':
end := strings.IndexByte(source[index+1:], '$')
if end >= 0 && end > 0 {
expression := source[index+1 : index+1+end]
if isSymbolsOnly(expression) {
// Client math fonts often lack exotic symbols; the
// regular text font renders them everywhere.
text.WriteString(strings.TrimSpace(expression))
} else {
flush()
parts = append(parts, Inline{Type: "mathematical_expression", Expression: expression})
}
index += end + 2
continue
}
text.WriteByte(char)
index++
case char == '~':
text.WriteByte(' ')
index++
default:
text.WriteByte(char)
index++
}
}
flush()
if len(parts) == 1 {
if value, ok := parts[0].(string); ok {
return value
}
}
if len(parts) == 0 {
return ""
}
return parts
}
// skipEmptyGroup skips an empty {} group immediately following a control
// word, as in \LaTeX{}.
func skipEmptyGroup(source string, index int) int {
if strings.HasPrefix(source[index:], "{}") {
return index + 2
}
return index
}
// readCommand reads a backslash command name at position index.
func readCommand(source string, index int) (string, int) {
next := index + 1
name := ""
for next < len(source) && isCommandLetter(source[next]) {
name += string(source[next])
next++
}
if name == "" && next < len(source) {
// Single-character commands such as \, or \; .
name = string(source[next])
next++
}
return name, next
}
// readGroupAt reads a balanced {group} starting at position index, skipping
// spaces.
func readGroupAt(source string, index int) (string, int, bool) {
for index < len(source) && (source[index] == ' ' || source[index] == '\t') {
index++
}
if index >= len(source) || source[index] != '{' {
return "", index, false
}
depth := 0
start := index + 1
for position := index; position < len(source); position++ {
switch source[position] {
case '\\':
position++ // skip the escaped character
case '{':
depth++
case '}':
depth--
if depth == 0 {
return source[start:position], position + 1, true
}
}
}
return "", index, false
}

404
internal/rich/latex_test.go Normal file
View File

@@ -0,0 +1,404 @@
package rich
import (
"encoding/json"
"testing"
)
func TestDocumentPureMath(t *testing.T) {
message := Document(`\int_0^\infty e^{-x^2}\,dx`, true)
if len(message.Blocks) != 1 || message.Blocks[0].Type != "mathematical_expression" {
t.Fatalf("blocks = %+v", message.Blocks)
}
if message.Blocks[0].Expression != `\int_0^\infty e^{-x^2}\,dx` {
t.Fatalf("expression = %q", message.Blocks[0].Expression)
}
}
func TestDocumentDollarMath(t *testing.T) {
message := Document(`$F=ma$`, true)
if len(message.Blocks) != 1 || message.Blocks[0].Type != "paragraph" {
t.Fatalf("blocks = %+v", message.Blocks)
}
}
func TestDocumentDisplayMath(t *testing.T) {
message := Document("before\n\n$$F=ma$$\n\nafter", true)
if len(message.Blocks) != 3 {
t.Fatalf("blocks = %+v", message.Blocks)
}
if message.Blocks[0].Type != "paragraph" || message.Blocks[1].Type != "mathematical_expression" || message.Blocks[2].Type != "paragraph" {
t.Fatalf("block types = %s/%s/%s", message.Blocks[0].Type, message.Blocks[1].Type, message.Blocks[2].Type)
}
if message.Blocks[1].Expression != "F=ma" {
t.Fatalf("expression = %q", message.Blocks[1].Expression)
}
}
func TestDocumentTextFormatting(t *testing.T) {
message := Document(`Hello \textbf{world} and \emph{italics}`, true)
if len(message.Blocks) != 1 {
t.Fatalf("blocks = %+v", message.Blocks)
}
parts, ok := message.Blocks[0].Text.([]any)
if !ok || len(parts) != 4 {
t.Fatalf("text = %#v", message.Blocks[0].Text)
}
if parts[0] != "Hello " {
t.Errorf("parts[0] = %#v", parts[0])
}
bold, ok := parts[1].(Inline)
if !ok || bold.Type != "bold" || bold.Text != "world" {
t.Errorf("parts[1] = %#v", parts[1])
}
if parts[2] != " and " {
t.Errorf("parts[2] = %#v", parts[2])
}
italic, ok := parts[3].(Inline)
if !ok || italic.Type != "italic" || italic.Text != "italics" {
t.Errorf("parts[3] = %#v", parts[3])
}
}
func TestDocumentNestedFormatting(t *testing.T) {
message := Document(`\textbf{bold \emph{and italic}}`, true)
parts, ok := message.Blocks[0].Text.([]any)
if !ok || len(parts) != 1 {
t.Fatalf("text = %#v", message.Blocks[0].Text)
}
bold, ok := parts[0].(Inline)
if !ok || bold.Type != "bold" {
t.Fatalf("parts[0] = %#v", parts[0])
}
inner, ok := bold.Text.([]any)
if !ok || len(inner) != 2 {
t.Fatalf("bold text = %#v", bold.Text)
}
italic, ok := inner[1].(Inline)
if !ok || italic.Type != "italic" || italic.Text != "and italic" {
t.Fatalf("inner = %#v", inner[1])
}
}
func TestDocumentMixedTextAndMath(t *testing.T) {
message := Document(`The area is $\pi r^2$ exactly`, true)
parts, ok := message.Blocks[0].Text.([]any)
if !ok || len(parts) != 3 {
t.Fatalf("text = %#v", message.Blocks[0].Text)
}
math, ok := parts[1].(Inline)
if !ok || math.Type != "mathematical_expression" || math.Expression != `\pi r^2` {
t.Fatalf("math = %#v", parts[1])
}
}
func TestDocumentSections(t *testing.T) {
message := Document("\\section{Intro}\n\nSome text", true)
if len(message.Blocks) != 2 {
t.Fatalf("blocks = %+v", message.Blocks)
}
if message.Blocks[0].Type != "heading" || message.Blocks[0].Size != 2 {
t.Fatalf("heading = %+v", message.Blocks[0])
}
if message.Blocks[0].Text != "Intro" {
t.Fatalf("heading text = %#v", message.Blocks[0].Text)
}
}
func TestDocumentList(t *testing.T) {
message := Document("\\begin{itemize}\n\\item one\n\\item two\n\\end{itemize}", true)
if len(message.Blocks) != 2 {
t.Fatalf("blocks = %+v", message.Blocks)
}
if message.Blocks[0].Text != "• one" || message.Blocks[1].Text != "• two" {
t.Fatalf("items = %#v / %#v", message.Blocks[0].Text, message.Blocks[1].Text)
}
}
func TestDocumentEscapesAndCommands(t *testing.T) {
message := Document(`100\% \& more \LaTeX{} \url{https://example.com}`, true)
parts, ok := message.Blocks[0].Text.([]any)
if !ok {
t.Fatalf("text = %#v", message.Blocks[0].Text)
}
joined := ""
for _, part := range parts {
if text, ok := part.(string); ok {
joined += text
}
}
if joined != "100% & more LaTeX " {
t.Fatalf("joined = %q", joined)
}
url, ok := parts[len(parts)-1].(Inline)
if !ok || url.Type != "url" || url.URL != "https://example.com" {
t.Fatalf("url = %#v", parts[len(parts)-1])
}
}
func TestDocumentHyperrefCommands(t *testing.T) {
message := Document(`See \hyperlink{details}{the \textbf{details}}.`, true)
if len(message.Blocks) != 1 || message.Blocks[0].Type != "paragraph" {
t.Fatalf("blocks = %+v", message.Blocks)
}
parts, ok := message.Blocks[0].Text.([]any)
if !ok || len(parts) != 4 {
t.Fatalf("text = %#v", message.Blocks[0].Text)
}
if parts[0] != "See " || parts[1] != "the " || parts[3] != "." {
t.Fatalf("parts = %#v", parts)
}
bold, ok := parts[2].(Inline)
if !ok || bold.Type != "bold" || bold.Text != "details" {
t.Fatalf("bold = %#v", parts[2])
}
target := Document(`\hypertarget{details}{Details}`, true)
if len(target.Blocks) != 1 || target.Blocks[0].Type != "paragraph" || target.Blocks[0].Text != "Details" {
t.Fatalf("target blocks = %+v", target.Blocks)
}
}
func TestDocumentFullDocumentBody(t *testing.T) {
source := "\\documentclass{article}\n\\begin{document}\nHello \\textbf{world}\n\\end{document}"
message := Document(source, true)
if len(message.Blocks) != 1 {
t.Fatalf("blocks = %+v", message.Blocks)
}
if message.Blocks[0].Type != "paragraph" {
t.Fatalf("block = %+v", message.Blocks[0])
}
}
func TestInlineDocumentLeadingMath(t *testing.T) {
message := InlineDocument(`$F=ma$`, true)
if len(message.Blocks) != 1 {
t.Fatalf("blocks = %+v", message.Blocks)
}
parts, ok := message.Blocks[0].Text.([]any)
if !ok || len(parts) != 2 || parts[0] != " " {
t.Fatalf("text = %#v", message.Blocks[0].Text)
}
math, ok := parts[1].(Inline)
if !ok || math.Type != "mathematical_expression" || math.Expression != "F=ma" {
t.Fatalf("math = %#v", parts[1])
}
}
func TestInlineDocumentDisplayMath(t *testing.T) {
message := InlineDocument("$$F=ma$$", true)
if len(message.Blocks) != 1 || message.Blocks[0].Type != "paragraph" {
t.Fatalf("blocks = %+v", message.Blocks)
}
parts, ok := message.Blocks[0].Text.([]any)
if !ok || len(parts) != 2 || parts[0] != " " {
t.Fatalf("text = %#v", message.Blocks[0].Text)
}
}
func TestInlineDocumentLeadingParagraph(t *testing.T) {
message := InlineDocument(`Hello \textbf{world}`, true)
parts, ok := message.Blocks[0].Text.([]any)
if !ok || len(parts) == 0 || parts[0] != " " {
t.Fatalf("text = %#v", message.Blocks[0].Text)
}
}
func TestDocumentJSONMarshal(t *testing.T) {
data, err := json.Marshal(Document(`Hello \textbf{world}`, true))
if err != nil {
t.Fatalf("Marshal: %v", err)
}
expected := `{"blocks":[{"type":"paragraph","text":["Hello ",{"type":"bold","text":"world"}]}]}`
if string(data) != expected {
t.Fatalf("unexpected JSON:\n got %s\nwant %s", data, expected)
}
}
// blockText returns the math expression or the paragraph text of a block.
func blockText(block Block) string {
if block.Expression != "" {
return block.Expression
}
if text, ok := block.Text.(string); ok {
return text
}
return ""
}
func TestDocumentSymbolCommands(t *testing.T) {
// A lone symbol renders as text: client math fonts often lack exotic
// symbols, while the regular text font has them.
message := Document(`\heartsuit`, true)
if len(message.Blocks) != 1 || message.Blocks[0].Type != "paragraph" {
t.Fatalf("blocks = %+v", message.Blocks)
}
if blockText(message.Blocks[0]) != "♡" {
t.Fatalf("text = %q", blockText(message.Blocks[0]))
}
// With surrounding letters it stays a math expression.
message = Document(`I \heartsuit{} you`, true)
if message.Blocks[0].Type != "mathematical_expression" || blockText(message.Blocks[0]) != "I ♡ you" {
t.Fatalf("blocks = %+v", message.Blocks)
}
// Standard math commands are left to the client engine, which renders
// them natively (Unicode replacements are not drawn by every font).
message = Document(`\int_0^\infty e^{-x^2}\,dx`, true)
if blockText(message.Blocks[0]) != `\int_0^\infty e^{-x^2}\,dx` {
t.Fatalf("expression = %q", blockText(message.Blocks[0]))
}
for source, want := range map[string]string{
`\alpha + \beta = \gamma`: `\alpha + \beta = \gamma`,
`\sum_{i=1}^n i`: `\sum_{i=1}^n i`,
`a \leq b \to c`: `a \leq b \to c`,
`\mathbb{R}^n`: `\mathbb{R}^n`,
`\mathcal{L}(f)`: `\mathcal{L}(f)`,
`\mathfrak{g} \otimes \mathfrak{h}`: `\mathfrak{g} \otimes \mathfrak{h}`,
`\mathbf{v} \cdot \nabla f`: `\mathbf{v} \cdot \nabla f`,
`\heartsuit`: "♡",
} {
message := Document(source, true)
if got := blockText(message.Blocks[0]); got != want {
t.Errorf("source %q: got %q, want %q", source, got, want)
}
}
}
func TestDocumentIntegralCommandPreserved(t *testing.T) {
source := `$$\int_0^\infty e^{-x^{porcodio}}\,dx$$`
message := Document(source, true)
if len(message.Blocks) != 1 || message.Blocks[0].Type != "mathematical_expression" {
t.Fatalf("blocks = %+v", message.Blocks)
}
if got := message.Blocks[0].Expression; got != `\int_0^\infty e^{-x^{porcodio}}\,dx` {
t.Fatalf("expression = %q, integral command must stay for the client engine", got)
}
}
func TestDocumentSymbolMathSegments(t *testing.T) {
// A math segment containing only symbols is hoisted to text: the math
// font does not carry exotic glyphs, the regular font does.
for _, source := range []string{`$\heartsuit$`, `$$\heartsuit$$`, `\( \heartsuit \)`, `\[ \heartsuit \]`} {
message := Document(source, true)
if len(message.Blocks) != 1 || message.Blocks[0].Type != "paragraph" {
t.Errorf("source %q: blocks = %+v", source, message.Blocks)
continue
}
if got := blockText(message.Blocks[0]); got != "♡" {
t.Errorf("source %q: text = %q, want ♡", source, got)
}
}
// Symbols mixed with real math stay in a math node.
message := Document(`$x \heartsuit y$`, true)
if message.Blocks[0].Type != "paragraph" {
t.Fatalf("blocks = %+v", message.Blocks)
}
parts, ok := message.Blocks[0].Text.([]any)
if !ok || len(parts) != 1 {
t.Fatalf("text = %#v", message.Blocks[0].Text)
}
math, ok := parts[0].(Inline)
if !ok || math.Type != "mathematical_expression" || math.Expression != "x ♡ y" {
t.Fatalf("math = %#v", parts[0])
}
}
func TestIsMathExpression(t *testing.T) {
for source, want := range map[string]bool{
`\heartsuit`: true,
`\int_0^\infty x`: true,
`F = ma`: true,
`$F=ma$`: true,
`\begin{matrix} a & b \end{matrix}`: true,
`Hello \textbf{x}`: false,
`\hyperlink{x}{go}`: false,
`\begin{itemize} \item a`: false,
`\begin{itemize} \item $x$`: false,
} {
if got := IsMathExpression(source); got != want {
t.Errorf("IsMathExpression(%q) = %v, want %v", source, got, want)
}
}
}
func TestContainsMathEnvironment(t *testing.T) {
for source, want := range map[string]bool{
`\begin{align} a &= b \end{align}`: true,
`\begin{matrix} a & b \end{matrix}`: true,
`\begin{itemize} a`: false,
`x^2`: false,
} {
if got := ContainsMathEnvironment(source); got != want {
t.Errorf("ContainsMathEnvironment(%q) = %v, want %v", source, got, want)
}
}
}
func TestDocumentAlphabetFallback(t *testing.T) {
// A font command with non-alphanumeric content is left to the engine.
message := Document(`\mathbb{R_1}`, true)
if message.Blocks[0].Expression != `\mathbb{R_1}` {
t.Fatalf("expression = %q", message.Blocks[0].Expression)
}
}
func TestPureMathKeepsPlainText(t *testing.T) {
message := Document("F = ma", true)
if len(message.Blocks) != 1 || message.Blocks[0].Type != "mathematical_expression" {
t.Fatalf("blocks = %+v", message.Blocks)
}
}
func TestDocumentTextDefault(t *testing.T) {
// With defaultMath disabled a bare source is text, not math.
message := Document("F = ma", false)
if len(message.Blocks) != 1 || message.Blocks[0].Type != "paragraph" {
t.Fatalf("blocks = %+v", message.Blocks)
}
if message.Blocks[0].Text != "F = ma" {
t.Fatalf("text = %#v", message.Blocks[0].Text)
}
// Delimited math still renders as math.
message = Document("$F = ma$", false)
parts, ok := message.Blocks[0].Text.([]any)
if !ok || len(parts) != 1 {
t.Fatalf("text = %#v", message.Blocks[0].Text)
}
if math, ok := parts[0].(Inline); !ok || math.Type != "mathematical_expression" {
t.Fatalf("parts[0] = %#v", parts[0])
}
// Text-mode commands still work.
message = Document(`Hello \textbf{world}`, false)
if _, ok := message.Blocks[0].Text.([]any); !ok {
t.Fatalf("text = %#v", message.Blocks[0].Text)
}
}
func TestInlineDocumentTextDefault(t *testing.T) {
message := InlineDocument("F = ma", false)
if len(message.Blocks) != 1 || message.Blocks[0].Type != "paragraph" {
t.Fatalf("blocks = %+v", message.Blocks)
}
if message.Blocks[0].Text != " F = ma" {
t.Fatalf("text = %#v", message.Blocks[0].Text)
}
}
func TestDocumentBareMathCommands(t *testing.T) {
for _, source := range []string{
`\frac{1}{2}`,
`\begin{align} a &= b \\ c &= d \end{align}`,
`x = \text{hello}`,
} {
message := Document(source, true)
if len(message.Blocks) != 1 || message.Blocks[0].Type != "mathematical_expression" {
t.Errorf("source %q: blocks = %+v", source, message.Blocks)
}
}
}

28
internal/rich/rich.go Normal file
View File

@@ -0,0 +1,28 @@
// Package rich builds rich-message payloads for the Bot API 10.x rich
// message endpoints (sendRichMessage and the InputRichMessageContent used in
// inline answers). The pinned telegram-bot-api library predates the rich
// message API, so these are plain JSON-encodable values sent through
// api.MakeRequest.
package rich
// Message is the payload for InputRichMessage (and structurally for
// RichMessage). Only the blocks representation is used.
type Message struct {
Blocks []Block `json:"blocks"`
}
// Block is a RichBlock/InputRichBlock.
type Block struct {
Type string `json:"type"`
Text any `json:"text,omitempty"`
Expression string `json:"expression,omitempty"`
Size int `json:"size,omitempty"`
}
// Inline is a RichText inline node (mathematical_expression, bold, url...).
type Inline struct {
Type string `json:"type"`
Text any `json:"text,omitempty"`
Expression string `json:"expression,omitempty"`
URL string `json:"url,omitempty"`
}