Files
latexgrambot/internal/rich/latex.go
corrado.mulas 98a3614d58 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
2026-10-04 03:42:50 +02:00

743 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}