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
}