diff --git a/README.md b/README.md index ee461e9..262f17b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,9 @@ Inline LaTeX bot for Telegram, written in Go. the chat immediately. - **Rich text rendering**: replies are Telegram rich messages (Bot API 10.x `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, pgfplots, siunitx, mhchem, chemfig, braket, hyperref, ...) is preloaded 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_PREAMBLE_LEN` | `4000` | Max preamble length in characters | | `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 | | `PDFLATEX_BIN` | `pdflatex` | pdflatex binary | | `PDFTOPPM_BIN` | `pdftoppm` | pdftoppm binary | diff --git a/internal/config/config.go b/internal/config/config.go index 8f301c0..b44c63a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,9 +36,10 @@ type Config struct { // back to PNG and PDF only. RichTextEnabled bool - // RichDefaultMath controls how the rich format treats a bare source - // (no math delimiters, no text-mode command): true renders it as a math - // expression, false parses it as text so formulas need $...$ delimiters. + // RichDefaultMath controls how the rich format treats ambiguous bare + // source. It defaults to false so non-math LaTeX is never guessed to be + // math; formulas need explicit delimiters unless operators opt into the + // legacy behavior. RichDefaultMath bool RenderDPI int @@ -81,7 +82,7 @@ func Load() (*Config, error) { if cfg.RichTextEnabled, err = getenvBool("RICH_TEXT_ENABLED", true); err != nil { 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 } if cfg.RenderDPI, err = getenvInt("RENDER_DPI", 600); err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a77ed10..3247fe4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -39,8 +39,8 @@ func TestLoadDefaults(t *testing.T) { if !cfg.RichTextEnabled { t.Error("RichTextEnabled = false, want true by default") } - if !cfg.RichDefaultMath { - t.Error("RichDefaultMath = false, want true by default") + if cfg.RichDefaultMath { + t.Error("RichDefaultMath = true, want false by default") } if cfg.S3PresignTTL != time.Hour { t.Errorf("S3PresignTTL = %s, want 1h", cfg.S3PresignTTL) @@ -80,13 +80,13 @@ func TestLoadRejectsIncompleteS3(t *testing.T) { func TestLoadRichDefaultMathToggle(t *testing.T) { setRequired(t) - t.Setenv("RICH_DEFAULT_MATH", "false") + t.Setenv("RICH_DEFAULT_MATH", "true") cfg, err := Load() if err != nil { t.Fatalf("Load: %v", err) } - if cfg.RichDefaultMath { - t.Error("RichDefaultMath = true, want false") + if !cfg.RichDefaultMath { + t.Error("RichDefaultMath = false, want true") } t.Setenv("RICH_DEFAULT_MATH", "banana") diff --git a/internal/rich/latex.go b/internal/rich/latex.go index a1683d2..84d1911 100644 --- a/internal/rich/latex.go +++ b/internal/rich/latex.go @@ -11,11 +11,85 @@ 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, + "textup": true, "textmd": true, "textsl": true, "textsc": true, + "textcolor": true, "color": true, "colorbox": true, "fcolorbox": 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, + "nolinkurl": true, "label": true, "ref": true, "pageref": true, + "autoref": true, "part": true, "chapter": true, "section": 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 @@ -136,17 +210,16 @@ func IsMathExpression(source string) bool { 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) { + environments := environmentsIn(trimmed) + for _, environment := range environments { if !mathEnvironments[environment] { return false } } - return true + if len(environments) > 0 { + return true + } + return isPureMath(trimmed) } // 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, `\[`) { return false } - for _, command := range commandsIn(source) { - if textCommands[command] { - return false - } - } - for _, env := range environmentsIn(source) { + environments := environmentsIn(source) + for _, env := range environments { if !mathEnvironments[env] { 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 { var commands []string for i := 0; i < len(source); i++ { @@ -340,6 +527,16 @@ func (p *parser) parseBlocks() []Block { if trimmed == "" { 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)}) } @@ -418,6 +615,30 @@ func (p *parser) parseBlocks() []Block { 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 // followed by a blank line (two or more newlines, spaces allowed between). func (p *parser) blankLineAhead() bool { @@ -544,6 +765,13 @@ func parseInline(source string) any { continue } 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 { case "textbf": if hasArg { @@ -689,6 +917,20 @@ func parseInline(source string) any { 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 // word, as in \LaTeX{}. func skipEmptyGroup(source string, index int) int { diff --git a/internal/rich/latex_test.go b/internal/rich/latex_test.go index 035027c..bb27c59 100644 --- a/internal/rich/latex_test.go +++ b/internal/rich/latex_test.go @@ -2,6 +2,7 @@ package rich import ( "encoding/json" + "strings" "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) { message := Document("\\begin{itemize}\n\\item one\n\\item two\n\\end{itemize}", true) if len(message.Blocks) != 2 { @@ -237,9 +285,10 @@ func TestDocumentSymbolCommands(t *testing.T) { 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) - 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) } @@ -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) { for source, want := range map[string]bool{ `\begin{align} a &= b \end{align}`: true,