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.
|
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 |
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user