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:
742
internal/rich/latex.go
Normal file
742
internal/rich/latex.go
Normal 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
404
internal/rich/latex_test.go
Normal 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
28
internal/rich/rich.go
Normal 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user