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
743 lines
21 KiB
Go
743 lines
21 KiB
Go
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
|
||
}
|