Handle rich text sizes and classify math conservatively
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user