Handle rich text sizes and classify math conservatively

This commit is contained in:
corrado.mulas
2026-10-04 04:50:30 +02:00
parent 835d1f308b
commit 167f01426e
5 changed files with 351 additions and 31 deletions

View File

@@ -18,7 +18,9 @@ Inline LaTeX bot for Telegram, written in Go.
the chat immediately. the chat immediately.
- **Rich text rendering**: replies are Telegram rich messages (Bot API 10.x - **Rich text rendering**: replies are Telegram rich messages (Bot API 10.x
`sendRichMessage`) whose `mathematical_expression` block is rendered `sendRichMessage`) whose `mathematical_expression` block is rendered
natively by the client. The raw source is never echoed. natively by the client. Text formatting and the standard size declarations
(`\large` through `\Huge`) are converted to native rich blocks; ambiguous
bare input stays text by default. The raw source is never echoed.
- **Full LaTeX**: a broad default package set (amsmath, mathtools, tikz, - **Full LaTeX**: a broad default package set (amsmath, mathtools, tikz,
pgfplots, siunitx, mhchem, chemfig, braket, hyperref, ...) is preloaded pgfplots, siunitx, mhchem, chemfig, braket, hyperref, ...) is preloaded
into a TeX format at image build time, so any of it is available without into a TeX format at image build time, so any of it is available without
@@ -185,7 +187,7 @@ render.
| `MAX_EXPRESSION_LEN` | `4000` | Max expression length in characters | | `MAX_EXPRESSION_LEN` | `4000` | Max expression length in characters |
| `MAX_PREAMBLE_LEN` | `4000` | Max preamble length in characters | | `MAX_PREAMBLE_LEN` | `4000` | Max preamble length in characters |
| `RICH_TEXT_ENABLED` | `true` | Rich text replies (needs a Bot API 10.x server) | | `RICH_TEXT_ENABLED` | `true` | Rich text replies (needs a Bot API 10.x server) |
| `RICH_DEFAULT_MATH` | `true` | A bare source without delimiters/text commands renders as math; `false` parses it as text, so formulas need `$...$` | | `RICH_DEFAULT_MATH` | `false` | Parse ambiguous bare source as text; set `true` for legacy automatic bare-math detection |
| `HEALTH_ADDR` | `:8080` | Health endpoint address | | `HEALTH_ADDR` | `:8080` | Health endpoint address |
| `PDFLATEX_BIN` | `pdflatex` | pdflatex binary | | `PDFLATEX_BIN` | `pdflatex` | pdflatex binary |
| `PDFTOPPM_BIN` | `pdftoppm` | pdftoppm binary | | `PDFTOPPM_BIN` | `pdftoppm` | pdftoppm binary |

View File

@@ -36,9 +36,10 @@ type Config struct {
// back to PNG and PDF only. // back to PNG and PDF only.
RichTextEnabled bool RichTextEnabled bool
// RichDefaultMath controls how the rich format treats a bare source // RichDefaultMath controls how the rich format treats ambiguous bare
// (no math delimiters, no text-mode command): true renders it as a math // source. It defaults to false so non-math LaTeX is never guessed to be
// expression, false parses it as text so formulas need $...$ delimiters. // math; formulas need explicit delimiters unless operators opt into the
// legacy behavior.
RichDefaultMath bool RichDefaultMath bool
RenderDPI int RenderDPI int
@@ -81,7 +82,7 @@ func Load() (*Config, error) {
if cfg.RichTextEnabled, err = getenvBool("RICH_TEXT_ENABLED", true); err != nil { if cfg.RichTextEnabled, err = getenvBool("RICH_TEXT_ENABLED", true); err != nil {
return nil, err return nil, err
} }
if cfg.RichDefaultMath, err = getenvBool("RICH_DEFAULT_MATH", true); err != nil { if cfg.RichDefaultMath, err = getenvBool("RICH_DEFAULT_MATH", false); err != nil {
return nil, err return nil, err
} }
if cfg.RenderDPI, err = getenvInt("RENDER_DPI", 600); err != nil { if cfg.RenderDPI, err = getenvInt("RENDER_DPI", 600); err != nil {

View File

@@ -39,8 +39,8 @@ func TestLoadDefaults(t *testing.T) {
if !cfg.RichTextEnabled { if !cfg.RichTextEnabled {
t.Error("RichTextEnabled = false, want true by default") t.Error("RichTextEnabled = false, want true by default")
} }
if !cfg.RichDefaultMath { if cfg.RichDefaultMath {
t.Error("RichDefaultMath = false, want true by default") t.Error("RichDefaultMath = true, want false by default")
} }
if cfg.S3PresignTTL != time.Hour { if cfg.S3PresignTTL != time.Hour {
t.Errorf("S3PresignTTL = %s, want 1h", cfg.S3PresignTTL) t.Errorf("S3PresignTTL = %s, want 1h", cfg.S3PresignTTL)
@@ -80,13 +80,13 @@ func TestLoadRejectsIncompleteS3(t *testing.T) {
func TestLoadRichDefaultMathToggle(t *testing.T) { func TestLoadRichDefaultMathToggle(t *testing.T) {
setRequired(t) setRequired(t)
t.Setenv("RICH_DEFAULT_MATH", "false") t.Setenv("RICH_DEFAULT_MATH", "true")
cfg, err := Load() cfg, err := Load()
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
} }
if cfg.RichDefaultMath { if !cfg.RichDefaultMath {
t.Error("RichDefaultMath = true, want false") t.Error("RichDefaultMath = false, want true")
} }
t.Setenv("RICH_DEFAULT_MATH", "banana") t.Setenv("RICH_DEFAULT_MATH", "banana")

View File

@@ -11,11 +11,85 @@ var textCommands = map[string]bool{
"textbf": true, "textit": true, "emph": true, "underline": true, "textbf": true, "textit": true, "emph": true, "underline": true,
"uline": true, "texttt": true, "textsuperscript": true, "uline": true, "texttt": true, "textsuperscript": true,
"textsubscript": true, "textrm": true, "textsf": true, "textnormal": true, "textsubscript": true, "textrm": true, "textsf": true, "textnormal": true,
"textup": true, "textmd": true, "textcolor": true, "textup": true, "textmd": true, "textsl": true, "textsc": true,
"textcolor": true, "color": true, "colorbox": true, "fcolorbox": true,
"href": true, "url": true, "hyperlink": true, "hypertarget": true, "href": true, "url": true, "hyperlink": true, "hypertarget": true,
"section": true, "subsection": true, "nolinkurl": true, "label": true, "ref": true, "pageref": true,
"subsubsection": true, "item": true, "maketitle": true, "title": true, "autoref": true, "part": true, "chapter": true, "section": true,
"author": true, "documentclass": true, "par": true, "subsection": true, "subsubsection": true, "paragraph": true,
"subparagraph": true, "item": true, "maketitle": true, "title": true,
"author": true, "date": true, "thanks": true, "documentclass": true,
"usepackage": true, "includegraphics": true, "caption": true,
"footnote": true, "marginpar": true, "par": true, "newline": true,
"linebreak": true, "pagebreak": true, "newpage": true,
"clearpage": true, "cleardoublepage": true, "noindent": true,
"indent": true, "centering": true, "raggedright": true,
"raggedleft": true, "hspace": true, "vspace": true, "hfill": true,
"vfill": true, "smallskip": true, "medskip": true, "bigskip": true,
"mbox": true, "makebox": true, "parbox": true, "fbox": true,
"raisebox": true, "rotatebox": true, "resizebox": true,
"bfseries": true, "mdseries": true, "rmfamily": true,
"sffamily": true, "ttfamily": true, "upshape": true, "itshape": true,
"slshape": true, "scshape": true, "normalfont": true,
"LaTeX": true, "TeX": true, "copyright": true, "registered": true,
"circledR": true, "pounds": true, "euro": true, "yen": true,
"degree": true, "celsius": true, "textbullet": true, "S": true, "P": true,
"tiny": true, "scriptsize": true, "footnotesize": true,
"small": true, "normalsize": true, "large": true, "Large": true,
"LARGE": true, "huge": true, "Huge": true,
}
// mathCommands is deliberately a positive allowlist. An unknown command may
// be a text macro from a user's preamble, so it must never cause automatic
// math wrapping. Users can remove that ambiguity with explicit delimiters.
var mathCommands = commandSet(`
frac dfrac tfrac binom dbinom tbinom sqrt root
overline underline overbrace underbrace overset underset stackrel
hat widehat check widecheck breve acute grave tilde widetilde bar vec
dot ddot dddot ddddot mathring
left right middle big Big bigg Bigg bigl bigr Bigl Bigr biggl biggr Biggl Biggr
mathrm mathbf mathsf mathtt mathit mathnormal mathcal mathbb mathfrak
boldsymbol pmb operatorname text mod bmod pmod pod
lim liminf limsup max min sup inf det gcd Pr log ln exp sin cos tan cot sec csc
arcsin arccos arctan sinh cosh tanh coth ker dim hom arg deg
alpha beta gamma delta epsilon varepsilon zeta eta theta vartheta iota kappa
varkappa lambda mu nu xi omicron pi varpi rho varrho sigma varsigma tau
upsilon phi varphi chi psi omega Gamma Delta Theta Lambda Xi Pi Sigma Upsilon Phi Psi Omega
aleph beth gimel daleth hbar hslash imath jmath ell wp Re Im partial nabla infty
forall exists nexists emptyset varnothing neg lnot top bot angle surd prime
pm mp times div cdot ast star circ bullet cap cup uplus sqcap sqcup vee wedge
setminus smallsetminus wr diamond bigtriangleup bigtriangledown triangleleft triangleright
oplus ominus otimes oslash odot bigcirc dagger dag ddagger ddag amalg
boxplus boxminus boxtimes boxdot ltimes rtimes
leq le geq ge neq ne equiv models prec succ sim perp preceq succeq simeq mid
ll gg asymp parallel subset supset approx bowtie subseteq supseteq cong
in ni owns propto vdash dashv notin nleq ngeq nless ngtr nsim ncong
leftarrow gets rightarrow to leftrightarrow uparrow downarrow updownarrow
Leftarrow Rightarrow Leftrightarrow Uparrow Downarrow Updownarrow mapsto longmapsto
longleftarrow longrightarrow longleftrightarrow Longleftarrow Longrightarrow Longleftrightarrow
nearrow searrow swarrow nwarrow hookleftarrow hookrightarrow
sum prod coprod bigcup bigcap bigvee bigwedge bigoplus bigotimes bigodot biguplus bigsqcup
int iint iiint iiiint oint oiint oiiint smallint
lceil rceil lfloor rfloor lbrace rbrace langle rangle lvert rvert lVert rVert vert Vert
dots ldots cdots vdots ddots iddots mathellipsis
`)
func commandSet(names string) map[string]bool {
set := make(map[string]bool)
for _, name := range strings.Fields(names) {
set[name] = true
}
return set
}
// sizeCommands contains LaTeX's declaration-style font-size commands. Rich
// messages don't have arbitrary inline font sizes; their only larger text
// primitive is a section heading. Map the five enlarged sizes monotonically
// to heading levels and treat normal/reduced sizes as ordinary paragraphs.
var sizeCommands = map[string]int{
"tiny": 0, "scriptsize": 0, "footnotesize": 0,
"small": 0, "normalsize": 0,
"large": 5, "Large": 4, "LARGE": 3, "huge": 2, "Huge": 1,
} }
// wrapperEnvironments are environments whose markers are dropped from text // wrapperEnvironments are environments whose markers are dropped from text
@@ -136,17 +210,16 @@ func IsMathExpression(source string) bool {
if strings.Contains(trimmed, "$") || strings.Contains(trimmed, `\(`) || strings.Contains(trimmed, `\[`) { if strings.Contains(trimmed, "$") || strings.Contains(trimmed, `\(`) || strings.Contains(trimmed, `\[`) {
return false return false
} }
for _, command := range commandsIn(trimmed) { environments := environmentsIn(trimmed)
if textCommands[command] { for _, environment := range environments {
return false
}
}
for _, environment := range environmentsIn(trimmed) {
if !mathEnvironments[environment] { if !mathEnvironments[environment] {
return false return false
} }
} }
return true if len(environments) > 0 {
return true
}
return isPureMath(trimmed)
} }
// ContainsMathEnvironment reports whether the source contains a math // ContainsMathEnvironment reports whether the source contains a math
@@ -242,19 +315,133 @@ func isPureMath(source string) bool {
if strings.Contains(source, "$") || strings.Contains(source, `\(`) || strings.Contains(source, `\[`) { if strings.Contains(source, "$") || strings.Contains(source, `\(`) || strings.Contains(source, `\[`) {
return false return false
} }
for _, command := range commandsIn(source) { environments := environmentsIn(source)
if textCommands[command] { for _, env := range environments {
return false
}
}
for _, env := range environmentsIn(source) {
if !mathEnvironments[env] { if !mathEnvironments[env] {
return false return false
} }
} }
return true if len(environments) > 0 {
return true
}
commands := commandsIn(source)
hasMathCommand := false
for _, command := range commands {
if textCommands[command] {
return false
}
if isMathSpacingCommand(command) {
continue
}
if !mathCommands[command] {
if _, ok := symbolCommands[command]; ok {
hasMathCommand = true
continue
}
return false
}
hasMathCommand = true
}
if hasMathCommand {
return true
}
return hasMathSyntax(source)
} }
func isMathSpacingCommand(command string) bool {
switch command {
case ",", ";", ":", "!", " ":
return true
default:
return false
}
}
// hasMathSyntax recognizes strong math-only signals in command-free input.
// Plain words and punctuation stay text; ambiguous input can always opt into
// math with explicit delimiters.
func hasMathSyntax(source string) bool {
if strings.Contains(source, "://") {
return false
}
if strings.ContainsAny(source, "^_=<>") {
return true
}
trimmed := strings.TrimSpace(source)
if len(trimmed) > 1 && (trimmed[0] == '+' || trimmed[0] == '-') && isASCIIDigit(trimmed[1]) {
return true
}
for index := 1; index+1 < len(source); index++ {
switch source[index] {
case '+', '*', '/':
left, right, ok := surroundingNonSpace(source, index)
if ok && isMathOperand(left) && isMathOperand(right) &&
(isASCIIDigit(left) || isASCIIDigit(right) || isSingleLetterOperation(source, index)) {
return true
}
case '-':
left, right, ok := surroundingNonSpace(source, index)
if ok && (isASCIIDigit(left) || isASCIIDigit(right) || isSingleLetterOperation(source, index)) {
return true
}
}
}
return false
}
func surroundingNonSpace(source string, index int) (byte, byte, bool) {
left := index - 1
for left >= 0 && unicode.IsSpace(rune(source[left])) {
left--
}
right := index + 1
for right < len(source) && unicode.IsSpace(rune(source[right])) {
right++
}
if left < 0 || right >= len(source) {
return 0, 0, false
}
return source[left], source[right], true
}
func isSingleLetterOperation(source string, index int) bool {
left := index - 1
for left >= 0 && unicode.IsSpace(rune(source[left])) {
left--
}
right := index + 1
for right < len(source) && unicode.IsSpace(rune(source[right])) {
right++
}
if left < 0 || right >= len(source) || !isASCIILetter(source[left]) || !isASCIILetter(source[right]) {
return false
}
before := left - 1
for before >= 0 && unicode.IsSpace(rune(source[before])) {
before--
}
after := right + 1
for after < len(source) && unicode.IsSpace(rune(source[after])) {
after++
}
return (before < 0 || isMathBoundary(source[before])) &&
(after >= len(source) || isMathBoundary(source[after]))
}
func isASCIILetter(char byte) bool {
return (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z')
}
func isMathBoundary(char byte) bool {
return strings.ContainsRune("+-*/=<>^_,()[]{}", rune(char))
}
func isMathOperand(char byte) bool {
return isASCIIDigit(char) || (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || char == ')' || char == '}'
}
func isASCIIDigit(char byte) bool { return char >= '0' && char <= '9' }
func commandsIn(source string) []string { func commandsIn(source string) []string {
var commands []string var commands []string
for i := 0; i < len(source); i++ { for i := 0; i < len(source); i++ {
@@ -340,6 +527,16 @@ func (p *parser) parseBlocks() []Block {
if trimmed == "" { if trimmed == "" {
return return
} }
if content, size, ok := leadingSizeDeclaration(trimmed); ok {
if content == "" {
return
}
if size > 0 {
blocks = append(blocks, Block{Type: "heading", Text: parseInline(content), Size: size})
return
}
trimmed = content
}
blocks = append(blocks, Block{Type: "paragraph", Text: parseInline(trimmed)}) blocks = append(blocks, Block{Type: "paragraph", Text: parseInline(trimmed)})
} }
@@ -418,6 +615,30 @@ func (p *parser) parseBlocks() []Block {
return blocks return blocks
} }
// leadingSizeDeclaration strips a declaration-style font-size command at the
// beginning of a block and returns the closest supported rich heading size.
func leadingSizeDeclaration(source string) (content string, size int, ok bool) {
source = strings.TrimSpace(source)
if inner, after, grouped := readGroupAt(source, 0); grouped && after == len(source) {
if content, size, ok = leadingSizeDeclaration(inner); ok {
return content, size, true
}
}
if !strings.HasPrefix(source, `\`) {
return source, 0, false
}
name, next := readCommand(source, 0)
size, ok = sizeCommands[name]
if !ok {
return source, 0, false
}
content = strings.TrimSpace(source[next:])
if inner, after, grouped := readGroupAt(content, 0); grouped && after == len(content) {
content = strings.TrimSpace(inner)
}
return content, size, true
}
// blankLineAhead reports whether the newline at the current position is // blankLineAhead reports whether the newline at the current position is
// followed by a blank line (two or more newlines, spaces allowed between). // followed by a blank line (two or more newlines, spaces allowed between).
func (p *parser) blankLineAhead() bool { func (p *parser) blankLineAhead() bool {
@@ -544,6 +765,13 @@ func parseInline(source string) any {
continue continue
} }
arg, after, hasArg := readGroupAt(source, next) arg, after, hasArg := readGroupAt(source, next)
if _, ok := sizeCommands[name]; ok {
// Declarations are handled at block level when possible. Inside
// another construct, discard the unsupported size change instead
// of leaking the LaTeX command into the visible rich text.
index = skipControlWordSpace(source, skipEmptyGroup(source, next))
continue
}
switch name { switch name {
case "textbf": case "textbf":
if hasArg { if hasArg {
@@ -689,6 +917,20 @@ func parseInline(source string) any {
return parts return parts
} }
// skipControlWordSpace consumes whitespace following a control word, as TeX
// does for declaration commands such as \Huge and \small.
func skipControlWordSpace(source string, index int) int {
for index < len(source) {
switch source[index] {
case ' ', '\t', '\r', '\n':
index++
default:
return index
}
}
return index
}
// skipEmptyGroup skips an empty {} group immediately following a control // skipEmptyGroup skips an empty {} group immediately following a control
// word, as in \LaTeX{}. // word, as in \LaTeX{}.
func skipEmptyGroup(source string, index int) int { func skipEmptyGroup(source string, index int) int {

View File

@@ -2,6 +2,7 @@ package rich
import ( import (
"encoding/json" "encoding/json"
"strings"
"testing" "testing"
) )
@@ -105,6 +106,53 @@ func TestDocumentSections(t *testing.T) {
} }
} }
func TestDocumentSizeDeclarations(t *testing.T) {
for source, wantSize := range map[string]int{
`\large GRANDE`: 5,
`\Large GRANDE`: 4,
`\LARGE GRANDE`: 3,
`\huge GRANDE`: 2,
`\Huge GRANDE`: 1,
`\Huge{GRANDE}`: 1,
`{\Huge GRANDE}`: 1,
`{ \Huge GRANDE }`: 1,
} {
message := Document(source, true)
if len(message.Blocks) != 1 {
t.Errorf("source %q: blocks = %+v", source, message.Blocks)
continue
}
block := message.Blocks[0]
if block.Type != "heading" || block.Size != wantSize || block.Text != "GRANDE" {
t.Errorf("source %q: block = %+v", source, block)
}
if IsMathExpression(source) {
t.Errorf("source %q classified as math", source)
}
}
for _, source := range []string{`\normalsize normal`, `\small small`, `\footnotesize footnote`, `\scriptsize script`, `\tiny tiny`} {
message := Document(source, true)
if len(message.Blocks) != 1 || message.Blocks[0].Type != "paragraph" {
t.Errorf("source %q: blocks = %+v", source, message.Blocks)
}
if strings.Contains(blockText(message.Blocks[0]), `\`) {
t.Errorf("source %q leaked its declaration: %+v", source, message.Blocks[0])
}
}
}
func TestDocumentHugeJSON(t *testing.T) {
data, err := json.Marshal(InlineDocument(`\Huge GRANDE`, true))
if err != nil {
t.Fatalf("Marshal: %v", err)
}
want := `{"blocks":[{"type":"heading","text":"GRANDE","size":1}]}`
if string(data) != want {
t.Fatalf("unexpected JSON:\n got %s\nwant %s", data, want)
}
}
func TestDocumentList(t *testing.T) { func TestDocumentList(t *testing.T) {
message := Document("\\begin{itemize}\n\\item one\n\\item two\n\\end{itemize}", true) message := Document("\\begin{itemize}\n\\item one\n\\item two\n\\end{itemize}", true)
if len(message.Blocks) != 2 { if len(message.Blocks) != 2 {
@@ -237,9 +285,10 @@ func TestDocumentSymbolCommands(t *testing.T) {
t.Fatalf("text = %q", blockText(message.Blocks[0])) t.Fatalf("text = %q", blockText(message.Blocks[0]))
} }
// With surrounding letters it stays a math expression. // With surrounding words it stays text; a translated symbol isn't a
// reason to guess that the whole sentence is math.
message = Document(`I \heartsuit{} you`, true) message = Document(`I \heartsuit{} you`, true)
if message.Blocks[0].Type != "mathematical_expression" || blockText(message.Blocks[0]) != "I ♡ you" { if message.Blocks[0].Type != "paragraph" || blockText(message.Blocks[0]) != "I ♡ you" {
t.Fatalf("blocks = %+v", message.Blocks) t.Fatalf("blocks = %+v", message.Blocks)
} }
@@ -325,6 +374,32 @@ func TestIsMathExpression(t *testing.T) {
} }
} }
func TestIsMathExpressionConservative(t *testing.T) {
for source, want := range map[string]bool{
`\Huge GRANDE`: false,
`\bfseries bold`: false,
`\includegraphics{x.png}`: false,
`\customtextmacro{content}`: false,
`Hello-world`: false,
`https://example.com/a/b`: false,
`plain words`: false,
`x^2`: true,
`F = ma`: true,
`2 + 2`: true,
`a+b`: true,
`x + y + z`: true,
`x-y`: true,
`state-of-the-art`: false,
`\frac{1}{2}`: true,
`\alpha + \beta`: true,
`\int_0^1 x\,dx`: true,
} {
if got := IsMathExpression(source); got != want {
t.Errorf("IsMathExpression(%q) = %v, want %v", source, got, want)
}
}
}
func TestContainsMathEnvironment(t *testing.T) { func TestContainsMathEnvironment(t *testing.T) {
for source, want := range map[string]bool{ for source, want := range map[string]bool{
`\begin{align} a &= b \end{align}`: true, `\begin{align} a &= b \end{align}`: true,