markup: TeX math as MathML !532

merged merged by cmc on 2026-09-29 06:33 UTC · krz/gitbay:math-294 into main

11 files changed, +1906 −9

Layout: unified · split

.gitbay/wiki/Parity.org +4 −1
@@ -57,6 +57,7 @@ browser-only and the iOS build screen unable to say more than the log.
57| request a review | yes | yes | yes | 57| request a review | yes | yes | yes |
58| choose body markup | yes | yes | yes | 58| choose body markup | yes | yes | yes |
59| preview body markup | n/a | yes | no | 59| preview body markup | n/a | yes | no |
60| TeX math as MathML | n/a | yes | no |
60| stacked merge requests | yes | yes | yes | 61| stacked merge requests | yes | yes | yes |
61| revisions | yes | yes | yes | 62| revisions | yes | yes | yes |
62| range-diff | yes | yes | yes | 63| range-diff | yes | yes | yes |
@@ -125,6 +126,7 @@ reviews, since an approval was of the diff against the old branch.
125| labels: list, colour | yes | yes | yes | 126| labels: list, colour | yes | yes | yes |
126| choose body markup | yes | yes | yes | 127| choose body markup | yes | yes | yes |
127| preview body markup | n/a | yes | no | 128| preview body markup | n/a | yes | no |
129| TeX math as MathML | n/a | yes | no |
128| issue templates | yes | yes | yes | 130| issue templates | yes | yes | yes |
129| milestone create, close, reopen | yes | yes | yes | 131| milestone create, close, reopen | yes | yes | yes |
130| org labels: set, list, remove | yes | yes | yes | 132| org labels: set, list, remove | yes | yes | yes |
@@ -155,7 +157,8 @@ every surface now offers the choice: the web's create forms, and the iOS
155composer on create, edit and comment. An edit starts on the format its 157composer on create, edit and comment. An edit starts on the format its
156body was stored in, since starting elsewhere would silently reinterpret 158body was stored in, since starting elsewhere would silently reinterpret
157it on the next save. Diff-line comments have no format column and are 159it on the next save. Diff-line comments have no format column and are
158always markdown. 160always markdown. TeX math in either format renders as MathML on the
161web (#294); the terminal shows the source, so the CLI row is =n/a=.
159 162
160Every web form that takes markup has a Preview button beside its own 163Every web form that takes markup has a Preview button beside its own
161submit: issue and merge request create, their edit and comment boxes, 164submit: issue and merge request create, their edit and comment boxes,
.gitbay/wiki/Users.org +19
@@ -299,6 +299,25 @@ does on the repository page, with relative links resolved against the
299file's directory; =source= in the file's action bar (or =?view=source=) 299file's directory; =source= in the file's action bar (or =?view=source=)
300shows the text instead. Other files show the text with highlighting. 300shows the text instead. Other files show the text with highlighting.
301 301
302TeX math renders as MathML wherever markdown or org renders: READMEs,
303files, wiki pages, issue, merge request and release bodies, comments,
304and their previews. Markdown takes =$…$= inline and =$$…$$= for display,
305either inline or with the =$$= lines on their own. A =$= followed by a
306space, or a closing =$= preceded by a space or followed by a digit, is a
307dollar sign, so =$5 and $10= stays prose; =\$= is always one. Org takes
308=$…$=, =$$…$$=, =\(…\)=, =\[…\]= and =\begin{…}…\end{…}=. Code spans
309and blocks are left alone. The supported TeX is a subset (letters,
310numbers, operators, scripts, =\frac=, =\sqrt=, Greek and common symbols,
311=\left=/=\right=, accents, =\text=, the =\math…= fonts, matrices,
312=cases= and =aligned=; the full list heads =internal/texmath/texmath.go=).
313Anything outside it, including macros, colours and links, shows as
314source, as does an expression over 8 KiB or nested more than 64 deep,
315and so does all math in a document after its first 1000 expressions or
316256 KiB of TeX. A =$$= line opens display math only when a line ending
317in =$$= follows before a blank line. MathML typed as raw HTML is
318stripped like any other markup the sanitizer does not allow. The CLI
319shows the source.
320
302* Organizations 321* Organizations
303 322
304Orgs share the owner namespace with users and own repositories at 323Orgs share the owner namespace with users and own repositories at
CHANGELOG.org +7
@@ -47,6 +47,13 @@ anything beyond "replace the binary and restart" is needed.
47 with ={items, next}=. =dashboard= gains =queries= for pinned ones; the 47 with ={items, next}=. =dashboard= gains =queries= for pinned ones; the
48 web shows them on the dashboard and at =/<you>/-/queries=. An account 48 web shows them on the dashboard and at =/<you>/-/queries=. An account
49 keeps at most 50 saved queries, 10 pinned (#292). 49 keeps at most 50 saved queries, 10 pinned (#292).
50- TeX math renders server-side as MathML in markdown (=$…$=, =$$…$$=)
51 and org (=$…$=, =\(…\)=, =\[…\]=, LaTeX environments) wherever
52 markup renders, through a subset converter in =internal/texmath=.
53 Its output is cleaned by a policy admitting exactly the elements and
54 attributes it emits; MathML written as raw HTML is still stripped.
55 Anything outside the subset, and math past 1000 expressions or
56 256 KiB of TeX in one document, shows as source (#294).
50 57
51* v1.38.0 — 2026-09-29 58* v1.38.0 — 2026-09-29
52 59
internal/autolink/autolink.go +2 −2
@@ -2,7 +2,7 @@
2// to the repository's issues and merge requests, owner/name#N (and !N) 2// to the repository's issues and merge requests, owner/name#N (and !N)
3// across repositories, and @user to owner pages. It operates on the HTML 3// across repositories, and @user to owner pages. It operates on the HTML
4// produced by the markdown/org pipeline, walking text nodes with a real 4// produced by the markdown/org pipeline, walking text nodes with a real
5// parser so nothing inside <a>, <code>, or <pre> is ever touched, and only 5// parser so nothing inside <a>, <code>, <pre> or <math> is ever touched, and only
6// references that actually resolve become links. 6// references that actually resolve become links.
7package autolink 7package autolink
8 8
@@ -36,7 +36,7 @@ var (
36) 36)
37 37
38// skip lists elements whose text must never be rewritten. 38// skip lists elements whose text must never be rewritten.
39var skip = map[string]bool{"a": true, "code": true, "pre": true, "script": true, "style": true} 39var skip = map[string]bool{"a": true, "code": true, "math": true, "pre": true, "script": true, "style": true}
40 40
41// Rewrite processes an HTML fragment, linking references relative to 41// Rewrite processes an HTML fragment, linking references relative to
42// defaultOwner/defaultName. On any parse failure the input is returned 42// defaultOwner/defaultName. On any parse failure the input is returned
internal/httpd/math.go added +436
@@ -0,0 +1,436 @@
1package httpd
2
3import (
4 "bytes"
5 "crypto/rand"
6 "encoding/hex"
7 "html/template"
8 "regexp"
9 "sort"
10 "strconv"
11 "strings"
12
13 "github.com/microcosm-cc/bluemonday"
14 "github.com/niklasfasching/go-org/org"
15 "github.com/yuin/goldmark"
16 "github.com/yuin/goldmark/ast"
17 "github.com/yuin/goldmark/parser"
18 "github.com/yuin/goldmark/renderer"
19 "github.com/yuin/goldmark/text"
20 "github.com/yuin/goldmark/util"
21
22 "gitbay.org/gitbay/internal/texmath"
23)
24
25// TeX math renders server-side as MathML, which browsers display natively,
26// so the page needs no script and the CSP does not change (#294).
27//
28// The converter is internal/texmath rather than a library. The pure-Go
29// options were treeblood (MIT), which writes \color and \class arguments
30// into attributes unescaped, expands \def macros without a bound (a 180-byte
31// input produced 3 MB), did not finish 5000 nested braces in 30 seconds and
32// logs to stderr; goldmark-mathml, which runs Temml in a JavaScript VM; and
33// converters inside large typesetting modules. texmath covers a documented
34// subset, refuses everything else, and bounds input size and nesting.
35//
36// MathML reaches a page only from the converter. ugcPolicy, which cleans
37// user-authored HTML, admits none of it; mathPolicy admits exactly what
38// texmath emits and cleans each converted expression.
39
40// mathPolicy admits the MathML texmath emits: its elements, and each
41// attribute only on its element and only with the values it writes.
42var mathPolicy = func() *bluemonday.Policy {
43 p := bluemonday.NewPolicy()
44 p.AllowElements(texmath.Elements...)
45 p.AllowNoAttrs().OnElements(texmath.Elements...)
46 for element, attrs := range texmath.Attrs {
47 for name, value := range attrs {
48 pattern := `^` + regexp.QuoteMeta(value) + `$`
49 if value == "<length>" {
50 pattern = `^-?[0-9]+(\.[0-9]+)?em$`
51 }
52 p.AllowAttrs(name).Matching(regexp.MustCompile(pattern)).OnElements(element)
53 }
54 }
55 return p
56}()
57
58// mathHTML renders one expression, or escapes its source when the converter
59// refuses it.
60func mathHTML(tex, source string, display bool) (string, bool) {
61 out, err := texmath.Convert(tex, display)
62 if err != nil {
63 return template.HTMLEscapeString(source), false
64 }
65 return mathPolicy.Sanitize(out), true
66}
67
68// Per document, math stops rendering after this many expressions or this
69// much TeX; later delimiters stay literal text.
70const (
71 maxMathExprs = 1000
72 maxMathBytes = 256 << 10
73)
74
75type mathBudget struct{ n, bytes int }
76
77func (b *mathBudget) take(size int) bool {
78 if b.n >= maxMathExprs || b.bytes+size > maxMathBytes {
79 return false
80 }
81 b.n++
82 b.bytes += size
83 return true
84}
85
86// Markdown: $…$ inline and $$…$$ display, inline or as a block.
87
88var (
89 kindMath = ast.NewNodeKind("Math")
90 kindMathBlock = ast.NewNodeKind("MathBlock")
91
92 mathBudgetKey = parser.NewContextKey()
93 mathLineKey = parser.NewContextKey()
94)
95
96func budgetFor(pc parser.Context) *mathBudget {
97 b, _ := pc.Get(mathBudgetKey).(*mathBudget)
98 if b == nil {
99 b = &mathBudget{}
100 pc.Set(mathBudgetKey, b)
101 }
102 return b
103}
104
105type mathInline struct {
106 ast.BaseInline
107 tex string
108 display bool
109}
110
111func (n *mathInline) Kind() ast.NodeKind { return kindMath }
112func (n *mathInline) Dump(src []byte, level int) {
113 ast.DumpHelper(n, src, level, map[string]string{"TeX": n.tex}, nil)
114}
115
116type mathBlock struct {
117 ast.BaseBlock
118 tex []byte
119 source []byte
120 closed bool
121}
122
123func (n *mathBlock) Kind() ast.NodeKind { return kindMathBlock }
124func (n *mathBlock) Dump(src []byte, level int) {
125 ast.DumpHelper(n, src, level, map[string]string{"TeX": string(n.tex)}, nil)
126}
127
128func isMathSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' }
129
130// mathLine is the valid closing dollars of one line, as source offsets,
131// found in one pass so each opener on the line looks its closer up rather
132// than rescanning the rest of the line.
133type mathLine struct {
134 stop int
135 closers []int
136}
137
138// closers scans line, which starts at an opening $, for dollars that can
139// close inline math: a non-space before, no digit after, not escaped.
140func closers(line []byte, base int) []int {
141 var out []int
142 for i := 1; i < len(line); i++ {
143 switch line[i] {
144 case '\\':
145 i++
146 case '$':
147 if isMathSpace(line[i-1]) || i+1 < len(line) && line[i+1] >= '0' && line[i+1] <= '9' {
148 continue
149 }
150 out = append(out, base+i)
151 }
152 }
153 return out
154}
155
156// mathInlineParser follows pandoc's rule so prices stay prose: the opening
157// $ has a non-space after it, and the closing $ a non-space before it and no
158// digit after it. "$5 and $10" is text. A backslash escapes the next byte.
159type mathInlineParser struct{}
160
161func (mathInlineParser) Trigger() []byte { return []byte{'$'} }
162
163func (mathInlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
164 line, seg := block.PeekLine()
165 if len(line) >= 2 && line[1] == '$' {
166 // Scanning stops at the next $$, where the next attempt starts,
167 // so each byte is scanned at most twice.
168 body := line[2:]
169 for i := 0; i+1 < len(body); i++ {
170 if body[i] == '\\' {
171 i++
172 continue
173 }
174 if body[i] == '$' && body[i+1] == '$' {
175 if i == 0 || !budgetFor(pc).take(i) {
176 break
177 }
178 block.Advance(i + 4)
179 return &mathInline{tex: string(body[:i]), display: true}
180 }
181 }
182 // Consume both dollars so the second does not open inline math.
183 block.Advance(2)
184 return ast.NewTextSegment(seg.WithStop(seg.Start + 2))
185 }
186 if len(line) < 2 || isMathSpace(line[1]) {
187 return nil
188 }
189 start := seg.Start - seg.Padding
190 cache, _ := pc.Get(mathLineKey).(*mathLine)
191 if cache == nil || cache.stop != seg.Stop {
192 cache = &mathLine{stop: seg.Stop, closers: closers(line, start)}
193 pc.Set(mathLineKey, cache)
194 }
195 k := sort.SearchInts(cache.closers, start+2)
196 if k == len(cache.closers) {
197 return nil
198 }
199 end := cache.closers[k] - start
200 if !budgetFor(pc).take(end - 1) {
201 return nil
202 }
203 block.Advance(end + 1)
204 return &mathInline{tex: string(line[1:end])}
205}
206
207// mathBlockParser opens on a line that is $$ alone, when a line ending in
208// $$ follows before a blank line, or on a line that is $$…$$ whole.
209// Anything else stays paragraph text.
210type mathBlockParser struct{}
211
212func (mathBlockParser) Trigger() []byte { return []byte{'$'} }
213
214func (mathBlockParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
215 line, seg := reader.PeekLine()
216 pos := pc.BlockOffset()
217 if pos < 0 || !bytes.HasPrefix(line[pos:], []byte("$$")) {
218 return nil, parser.NoChildren
219 }
220 rest := util.TrimRightSpace(line[pos+2:])
221 n := &mathBlock{}
222 switch {
223 case len(rest) == 0:
224 // The lookahead stops at the first line ending in $$, which the
225 // block then consumes, so no line is looked at twice by it.
226 // It reads the source rather than moving the reader, whose
227 // SetPosition keeps the line it last peeked.
228 size, found := 0, false
229 src := reader.Source()
230 for i := seg.Stop; i < len(src); {
231 end := len(src)
232 if j := bytes.IndexByte(src[i:], '\n'); j >= 0 {
233 end = i + j + 1
234 }
235 next := src[i:end]
236 if util.IsBlank(next) {
237 break
238 }
239 size += len(next)
240 if bytes.HasSuffix(util.TrimRightSpace(next), []byte("$$")) {
241 found = true
242 break
243 }
244 i = end
245 }
246 if !found || !budgetFor(pc).take(size) {
247 return nil, parser.NoChildren
248 }
249 n.source = append(n.source, "$$\n"...)
250 case len(rest) > 2 && bytes.HasSuffix(rest, []byte("$$")):
251 if !budgetFor(pc).take(len(rest) - 2) {
252 return nil, parser.NoChildren
253 }
254 n.tex, n.closed = rest[:len(rest)-2], true
255 n.source = append([]byte("$$"), rest...)
256 default:
257 return nil, parser.NoChildren
258 }
259 reader.AdvanceToEOL()
260 return n, parser.NoChildren
261}
262
263func (mathBlockParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State {
264 n := node.(*mathBlock)
265 if n.closed {
266 return parser.Close
267 }
268 line, _ := reader.PeekLine()
269 if line == nil || util.IsBlank(line) {
270 return parser.Close
271 }
272 trimmed := util.TrimRightSpace(line)
273 if bytes.HasSuffix(trimmed, []byte("$$")) {
274 n.tex = append(n.tex, trimmed[:len(trimmed)-2]...)
275 n.source = append(n.source, trimmed...)
276 n.closed = true
277 reader.AdvanceToEOL()
278 return parser.Close
279 }
280 n.tex = append(n.tex, line...)
281 n.source = append(n.source, line...)
282 reader.AdvanceToEOL()
283 return parser.Continue | parser.NoChildren
284}
285
286func (mathBlockParser) Close(ast.Node, text.Reader, parser.Context) {}
287func (mathBlockParser) CanInterruptParagraph() bool { return true }
288func (mathBlockParser) CanAcceptIndentedLine() bool { return false }
289
290type mathRenderer struct{}
291
292func (mathRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
293 reg.Register(kindMath, func(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
294 if entering {
295 n := node.(*mathInline)
296 delim := "$"
297 if n.display {
298 delim = "$$"
299 }
300 out, _ := mathHTML(n.tex, delim+n.tex+delim, n.display)
301 _, _ = w.WriteString(out)
302 }
303 return ast.WalkSkipChildren, nil
304 })
305 reg.Register(kindMathBlock, func(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
306 if !entering {
307 return ast.WalkContinue, nil
308 }
309 n := node.(*mathBlock)
310 source := string(n.source)
311 out, ok := mathHTML(string(n.tex), source, true)
312 if !ok || !n.closed {
313 // A container that ended before the closing line leaves the
314 // block unclosed; show what it held.
315 out = "<pre>" + template.HTMLEscapeString(source) + "</pre>"
316 }
317 _, _ = w.WriteString(out + "\n")
318 return ast.WalkSkipChildren, nil
319 })
320}
321
322type mathExtension struct{}
323
324func (mathExtension) Extend(m goldmark.Markdown) {
325 m.Parser().AddOptions(
326 parser.WithBlockParsers(util.Prioritized(mathBlockParser{}, 701)),
327 parser.WithInlineParsers(util.Prioritized(mathInlineParser{}, 501)))
328 m.Renderer().AddOptions(renderer.WithNodeRenderers(util.Prioritized(mathRenderer{}, 500)))
329}
330
331// Org: go-org already parses $…$, $$…$$, \(…\), \[…\] and \begin{…}…\end{…}
332// as LaTeX fragments, and \begin{…} on its own lines as a LaTeX block; it
333// writes them back out as text. These render them instead.
334//
335// The whole org document goes through ugcPolicy, which admits no MathML, so
336// the writer leaves a placeholder for each expression and fill puts the
337// mathPolicy-cleaned MathML back after sanitizing. The placeholder carries a
338// random per-render prefix, so a document cannot spell one.
339
340type mathSlots struct {
341 prefix string
342 html []string
343 source []string
344 budget mathBudget
345}
346
347func newMathSlots() *mathSlots {
348 var b [12]byte
349 _, _ = rand.Read(b[:])
350 return &mathSlots{prefix: "gitbaymath" + hex.EncodeToString(b[:]) + "n"}
351}
352
353func (m *mathSlots) put(html, source string) string {
354 m.html = append(m.html, html)
355 m.source = append(m.source, source)
356 return m.prefix + strconv.Itoa(len(m.html)-1) + "z"
357}
358
359// fill replaces each placeholder in sanitized HTML with its MathML, or with
360// its escaped source where the placeholder landed inside a tag (an
361// attribute value). Sanitized output escapes < and > everywhere but in
362// tags, so the last of them seen says whether the text is inside one.
363func (m *mathSlots) fill(doc string) string {
364 if len(m.html) == 0 {
365 return doc
366 }
367 var b strings.Builder
368 inTag := false
369 for {
370 i := strings.Index(doc, m.prefix)
371 if i < 0 {
372 b.WriteString(doc)
373 return b.String()
374 }
375 before := doc[:i]
376 if j := strings.LastIndexAny(before, "<>"); j >= 0 {
377 inTag = before[j] == '<'
378 }
379 b.WriteString(before)
380 doc = doc[i+len(m.prefix):]
381 end := strings.IndexByte(doc, 'z')
382 if end < 0 {
383 b.WriteString(m.prefix)
384 continue
385 }
386 k, err := strconv.Atoi(doc[:end])
387 if err != nil || k < 0 || k >= len(m.html) {
388 b.WriteString(m.prefix)
389 continue
390 }
391 doc = doc[end+1:]
392 if inTag {
393 b.WriteString(template.HTMLEscapeString(m.source[k]))
394 } else {
395 b.WriteString(m.html[k])
396 }
397 }
398}
399
400// writeMath writes an expression's placeholder, or its source as text when
401// the converter refuses it or the document's budget is spent.
402func (w *orgWriter) writeMath(tex, source string, display bool) {
403 if w.math.budget.take(len(tex)) {
404 if out, ok := mathHTML(tex, source, display); ok {
405 w.WriteString(w.math.put(out, source))
406 return
407 }
408 }
409 w.WriteText(org.Text{Content: source, IsRaw: true})
410}
411
412func (w *orgWriter) WriteLatexFragment(l org.LatexFragment) {
413 tex := org.String(l.Content...)
414 source := l.OpeningPair + tex + l.ClosingPair
415 // go-org takes any $…$; org's own rule keeps "$5 and $10" prose.
416 if l.OpeningPair == "$" && (tex == "" || isMathSpace(tex[0]) || isMathSpace(tex[len(tex)-1])) {
417 w.WriteText(org.Text{Content: source, IsRaw: true})
418 return
419 }
420 display := l.OpeningPair != "$" && l.OpeningPair != `\(`
421 if strings.HasPrefix(l.OpeningPair, `\begin{`) {
422 tex = source
423 }
424 w.writeMath(tex, source, display)
425}
426
427func (w *orgWriter) WriteLatexBlock(b org.LatexBlock) {
428 tex := org.String(b.Content...)
429 if w.math.budget.take(len(tex)) {
430 if out, ok := mathHTML(tex, tex, true); ok {
431 w.WriteString(w.math.put(out, tex) + "\n")
432 return
433 }
434 }
435 w.WriteString("<pre>" + template.HTMLEscapeString(tex) + "</pre>\n")
436}
internal/httpd/math_test.go added +259
@@ -0,0 +1,259 @@
1package httpd
2
3import (
4 "net/http/httptest"
5 "regexp"
6 "strings"
7 "testing"
8 "time"
9
10 "gitbay.org/gitbay/internal/config"
11 "gitbay.org/gitbay/internal/store"
12)
13
14func TestMarkdownMath(t *testing.T) {
15 cases := []struct {
16 name, src string
17 want []string
18 not []string
19 }{
20 {"inline", "Euler: $e^{i\\pi}+1=0$.", []string{`<math><msup><mi>e</mi>`, `</math>.`}, nil},
21 {"display inline", "so $$x^2$$ here", []string{`<math display="block"><msup>`}, nil},
22 {"block", "text\n$$\n\\frac{a}{b}\n$$\nafter\n", []string{`<math display="block"><mfrac>`, `<p>after</p>`}, []string{"$$"}},
23 {"one-line block", "$$x_1$$\n", []string{`<math display="block"><msub>`}, []string{"<p>"}},
24 {"prices", "costs $5 and $10 today", []string{"costs $5 and $10 today"}, []string{"<math"}},
25 {"space after open", "a $ x$ b", []string{"a $ x$ b"}, []string{"<math"}},
26 {"space before close", "a $x $ b", []string{"a $x $ b"}, []string{"<math"}},
27 {"digit after close", "$x$5", []string{"$x$5"}, []string{"<math"}},
28 {"escaped dollars", `\$x\$`, []string{"$x$"}, []string{"<math"}},
29 {"escaped dollar inside", `$a\$b$`, []string{`<mo>$</mo>`}, nil},
30 {"code span", "`$x^2$`", []string{"<code>$x^2$</code>"}, []string{"<math"}},
31 {"fenced code", "```\n$x^2$\n$$\ny\n$$\n```\n", []string{"$x^2$", "$$\ny\n$$"}, []string{"<math"}},
32 {"indented code", " $x$\n", []string{"$x$"}, []string{"<math"}},
33 {"invalid inline", `see $\frac{a$ here`, []string{`see $\frac{a$ here`}, []string{"<math"}},
34 {"invalid block", "$$\n\\frac{\n$$\n", []string{"<pre tabindex=\"0\">$$\n\\frac{\n$$</pre>"}, []string{"<math"}},
35 {"unclosed block", "$$\nx\n", []string{"<p>$$\nx</p>"}, []string{"<math", "<pre"}},
36 {"blank line ends block", "$$\nx\n\ny $$\n", []string{"<p>$$\nx</p>", "<p>y $$</p>"}, []string{"<math", "<pre"}},
37 {"block keeps source", "$$\na\nb $$\n", []string{`<math display="block"><mi>a</mi><mi>b</mi></math>`}, nil},
38 {"markup is escaped", "$\\text{<b>&</b>}$", []string{`<mtext>&lt;b&gt;&amp;&lt;/b&gt;</mtext>`}, []string{"<b>"}},
39 }
40 for _, c := range cases {
41 out := string(ugcHTML(c.src, "md"))
42 for _, w := range c.want {
43 if !strings.Contains(out, w) {
44 t.Errorf("%s: lacks %q:\n%s", c.name, w, out)
45 }
46 }
47 for _, w := range c.not {
48 if strings.Contains(out, w) {
49 t.Errorf("%s: has %q:\n%s", c.name, w, out)
50 }
51 }
52 }
53}
54
55func TestOrgMath(t *testing.T) {
56 cases := []struct {
57 name, src string
58 want []string
59 not []string
60 }{
61 {"dollar", "Euler: $e^x$ here", []string{`<math><msup><mi>e</mi><mi>x</mi></msup></math> here`}, nil},
62 {"paren", `a \(x_1\) b`, []string{`<math><msub>`}, []string{`\(`}},
63 {"bracket", `a \[x^2\] b`, []string{`<math display="block"><msup>`}, []string{`\[`}},
64 {"double dollar", `a $$x$$ b`, []string{`<math display="block"><mi>x</mi></math>`}, nil},
65 {"environment block", "\\begin{equation}\nx = \\frac{1}{2}\n\\end{equation}\n", []string{`<math display="block"><mrow><mi>x</mi><mo>=</mo><mfrac>`}, []string{`\begin`}},
66 {"matrix block", "\\begin{pmatrix}\na & b \\\\\nc & d\n\\end{pmatrix}\n", []string{`<mtable><mtr><mtd><mi>a</mi></mtd>`}, nil},
67 {"prices", "costs $5 and $10 today", []string{"costs $5 and $10 today"}, []string{"<math"}},
68 {"verbatim", "=$x^2$= and ~$y$~", []string{"<code>$x^2$</code>", "<code>$y$</code>"}, []string{"<math"}},
69 {"src block", "#+begin_src tex\n$x^2$\n#+end_src\n", []string{"<pre"}, []string{"<math"}},
70 {"invalid", `see \(\frac{a\) here`, []string{`see \(\frac{a\) here`}, []string{"<math"}},
71 {"invalid block", "\\begin{tabular}\nx\n\\end{tabular}\n", []string{`<pre tabindex="0">\begin{tabular}`}, []string{"<math"}},
72 }
73 for _, c := range cases {
74 out := string(ugcHTML(c.src, "org"))
75 for _, w := range c.want {
76 if !strings.Contains(out, w) {
77 t.Errorf("%s: lacks %q:\n%s", c.name, w, out)
78 }
79 }
80 for _, w := range c.not {
81 if strings.Contains(out, w) {
82 t.Errorf("%s: has %q:\n%s", c.name, w, out)
83 }
84 }
85 }
86}
87
88// mathPolicy admits the MathML texmath writes and nothing else.
89func TestMathPolicy(t *testing.T) {
90 hostile := `<math display="block" xmlns:xlink="http://www.w3.org/1999/xlink" style="x" onclick="x()">` +
91 `<mi href="javascript:alert(1)" xlink:href="javascript:alert(2)" mathvariant="bold" style="color:red" onmouseover="x()">x</mi>` +
92 `<mo stretchy="true" form="prefix">(</mo><mspace width="expression(alert(3))"></mspace><mspace width="1em"></mspace>` +
93 `<semantics><annotation-xml encoding="text/html"><img src=x onerror="alert(4)"></annotation-xml></semantics>` +
94 `<maction actiontype="statusline"><mi>y</mi></maction><mstyle mathcolor="red"><mi>z</mi></mstyle>` +
95 `<mtext><style>*{}</style><script>alert(5)</script></mtext></math><math display="inline"></math>`
96 out := mathPolicy.Sanitize(hostile)
97 for _, bad := range []string{"href", "xlink", "style", "onclick", "onmouseover", "onerror", "javascript",
98 "annotation", "semantics", "maction", "mstyle", "mathcolor", "script", "expression",
99 `mathvariant="bold"`, `stretchy="true"`, "form=", `display="inline"`} {
100 if strings.Contains(out, bad) {
101 t.Errorf("sanitized MathML keeps %q:\n%s", bad, out)
102 }
103 }
104 for _, good := range []string{`<math display="block">`, `<mspace width="1em">`, "<mi>x</mi>", "<mi>y</mi>"} {
105 if !strings.Contains(out, good) {
106 t.Errorf("sanitized MathML lacks %q:\n%s", good, out)
107 }
108 }
109}
110
111// Hostile TeX is refused and shown as escaped source, in bounded time and
112// size, on both syntaxes.
113func TestHostileMath(t *testing.T) {
114 long := strings.Repeat(`x+`, 1<<19) // 1 MiB in one expression
115 deep := strings.Repeat("{", 50000) + "x" + strings.Repeat("}", 50000)
116 for _, tex := range []string{
117 `\href{javascript:alert(1)}{x}`, `\url{javascript:alert(1)}`, `\style{color:red}{x}`,
118 `\color{red" onmouseover="alert(1)}{x}`, `\class{a"b}{x}`, `\def\a{\a\a}\a`,
119 `\text{</math><script>alert(1)</script>}`, `\text{<img src=x onerror=alert(1)>}`,
120 deep, long, strings.Repeat(`\sqrt{`, 10000) + "x",
121 } {
122 for _, doc := range []struct{ src, format string }{
123 {"$" + tex + "$", "md"}, {"$$\n" + tex + "\n$$\n", "md"},
124 {`\(` + tex + `\)`, "org"}, {"\\[" + tex + "\\]", "org"},
125 } {
126 start := time.Now()
127 out := string(ugcHTML(doc.src, doc.format))
128 if d := time.Since(start); d > 2*time.Second {
129 t.Errorf("%.30q (%s) took %v", tex, doc.format, d)
130 }
131 if len(out) > 8*len(doc.src)+1024 {
132 t.Errorf("%.30q (%s): %d bytes out for %d in", tex, doc.format, len(out), len(doc.src))
133 }
134 for _, bad := range []string{"<script", "<img", `href="`, `onmouseover="`, `style="`, `class="a`} {
135 if strings.Contains(out, bad) {
136 t.Errorf("%.30q (%s) emits %q:\n%.300s", tex, doc.format, bad, out)
137 }
138 }
139 }
140 }
141 // A refused \text keeps its payload as visible text, escaped.
142 out := string(ugcHTML(`$\href{javascript:alert(1)}{x}$`, "md"))
143 if !strings.Contains(out, `$\href{javascript:alert(1)}{x}$`) || strings.Contains(out, "<math") || strings.Contains(out, "<a") {
144 t.Errorf("refused \\href: %s", out)
145 }
146}
147
148// The issue page renders its body's math through the shared path, and
149// autolinking leaves text inside <math> alone.
150func TestIssuePageRendersMath(t *testing.T) {
151 st, err := store.Open(":memory:")
152 if err != nil {
153 t.Fatal(err)
154 }
155 defer st.Close()
156 if err := st.MigrateUp(); err != nil {
157 t.Fatal(err)
158 }
159 uid, err := st.CreateUser("alice", false)
160 if err != nil {
161 t.Fatal(err)
162 }
163 repoID, err := st.CreateRepo("user", uid, "app", "public")
164 if err != nil {
165 t.Fatal(err)
166 }
167 if _, err := st.CreateIssue(repoID, uid, "math", `Area is $\pi r^2$, see $\text{#1}$.`, "md"); err != nil {
168 t.Fatal(err)
169 }
170 cfg := config.Default()
171 cfg.Web.Mode = "accounts"
172 s := New(cfg, st, nil)
173 rr := httptest.NewRecorder()
174 s.Handler().ServeHTTP(rr, httptest.NewRequest("GET", "/alice/app/issues/1", nil))
175 if rr.Code != 200 {
176 t.Fatalf("status %d", rr.Code)
177 }
178 body := rr.Body.String()
179 if !strings.Contains(body, `<math><mi>π</mi><msup><mi>r</mi><mn>2</mn></msup></math>`) {
180 t.Errorf("no MathML in the issue page:\n%s", body)
181 }
182 if !strings.Contains(body, `<mtext>#1</mtext>`) {
183 t.Errorf("autolink rewrote text inside <math>:\n%s", body)
184 }
185}
186
187// MathML written by hand is user HTML, and ugcPolicy strips it: only the
188// converter's output, through mathPolicy, reaches the page.
189func TestRawMathMLStripped(t *testing.T) {
190 raw := `<math display="block"><mi>q</mi></math>`
191 for name, out := range map[string]string{
192 "html readme": string(renderReadme("README.html", []byte(raw))),
193 "markdown html": string(renderReadme("README.md", []byte("para "+raw+"\n\n"+raw+"\n"))),
194 "org export": string(renderReadme("README.org", []byte("#+begin_export html\n"+raw+"\n#+end_export\n"))),
195 "org inline": string(renderReadme("README.org", []byte("@@html:"+raw+"@@\n"))),
196 } {
197 if strings.Contains(out, "<math") || strings.Contains(out, "<mi>") {
198 t.Errorf("%s keeps raw MathML:\n%s", name, out)
199 }
200 }
201 for name, out := range map[string]string{
202 "markdown": string(renderReadme("README.md", []byte("$q$\n"))),
203 "org": string(renderReadme("README.org", []byte("$q$\n"))),
204 } {
205 if !strings.Contains(out, "<math><mi>q</mi></math>") {
206 t.Errorf("%s: no MathML:\n%s", name, out)
207 }
208 }
209}
210
211// Org placeholders: a document cannot spell one, and one that lands in an
212// attribute is filled with escaped source, not markup.
213func TestMathSlots(t *testing.T) {
214 out := string(ugcHTML("gitbaymath0z $x$ gitbaymath00000000000000000000000000n0z", "org"))
215 if strings.Count(out, "<math>") != 1 || !strings.Contains(out, "gitbaymath0z") {
216 t.Errorf("forged placeholder: %s", out)
217 }
218 m := newMathSlots()
219 a := m.put(`<math><mi>x</mi></math>`, `$x" onmouseover="y$`)
220 b := m.put(`<math><mi>y</mi></math>`, `$y$`)
221 got := m.fill(`<a title="` + a + `">` + b + `</a>`)
222 want := `<a title="$x&#34; onmouseover=&#34;y$"><math><mi>y</mi></math></a>`
223 if got != want {
224 t.Errorf("fill:\n got %s\nwant %s", got, want)
225 }
226 if other := newMathSlots(); other.prefix == m.prefix {
227 t.Error("placeholder prefix repeats across renders")
228 }
229}
230
231// Many openers without closers on one line scan in linear time, and the
232// per-document budget leaves later math as text.
233func TestMathLinear(t *testing.T) {
234 for _, src := range []string{
235 strings.Repeat("$a ", 1<<20/3),
236 strings.Repeat("$$a ", 1<<20/4),
237 strings.Repeat("$$\na\n", 1<<20/5),
238 } {
239 for _, format := range []string{"md", "org"} {
240 start := time.Now()
241 ugcHTML(src, format)
242 if d := time.Since(start); d > 2*time.Second {
243 t.Errorf("%s: %.12q x %d took %v", format, src[:4], len(src), d)
244 }
245 }
246 }
247 src := strings.Repeat("$x$ ", maxMathExprs+10)
248 out := string(ugcHTML(src, "md"))
249 if n := strings.Count(out, "<math>"); n != maxMathExprs {
250 t.Errorf("markdown rendered %d expressions, budget %d", n, maxMathExprs)
251 }
252 out = string(ugcHTML(src, "org"))
253 if n := strings.Count(out, "<math>"); n != maxMathExprs {
254 t.Errorf("org rendered %d expressions, budget %d", n, maxMathExprs)
255 }
256 if regexp.MustCompile(`gitbaymath[0-9a-f]+n`).MatchString(out) {
257 t.Error("a placeholder survived")
258 }
259}
internal/httpd/web.go +9 −6
@@ -1192,13 +1192,13 @@ var imageTypes = map[string]string{
1192 1192
1193// markdown is the shared renderer: GFM (tables, strikethrough, autolinks, 1193// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
1194// task lists) on top of CommonMark, with class-based fence highlighting 1194// task lists) on top of CommonMark, with class-based fence highlighting
1195// (the palette lives in the stylesheet, per scheme). Raw HTML is still 1195// (the palette lives in the stylesheet, per scheme), and TeX math as
1196// dropped. 1196// MathML (math.go). Raw HTML is still dropped.
1197// Headings carry ids so a README or wiki section can be linked to, the 1197// Headings carry ids so a README or wiki section can be linked to, the
1198// way org headings already are (#132). 1198// way org headings already are (#132).
1199var markdown = goldmark.New( 1199var markdown = goldmark.New(
1200 goldmark.WithParserOptions(parser.WithAutoHeadingID()), 1200 goldmark.WithParserOptions(parser.WithAutoHeadingID()),
1201 goldmark.WithExtensions(extension.GFM, 1201 goldmark.WithExtensions(extension.GFM, mathExtension{},
1202 highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true))))) 1202 highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
1203 1203
1204// fenceHighlight renders one code block with chroma classes, for org and 1204// fenceHighlight renders one code block with chroma classes, for org and
@@ -1400,21 +1400,24 @@ func renderOrg(name string, raw []byte, contents bool, fallback func() template.
1400 } 1400 }
1401 return fenceHighlight(source, lang) 1401 return fenceHighlight(source, lang)
1402 } 1402 }
1403 writer.ExtendingWriter = &orgWriter{writer} 1403 ow := &orgWriter{HTMLWriter: writer, math: newMathSlots()}
1404 writer.ExtendingWriter = ow
1404 out, err := doc.Write(writer) 1405 out, err := doc.Write(writer)
1405 if err != nil { 1406 if err != nil {
1406 return fallback() 1407 return fallback()
1407 } 1408 }
1408 return imageAlt(template.HTML(ugcPolicy.Sanitize(out))) 1409 return imageAlt(template.HTML(ow.math.fill(ugcPolicy.Sanitize(out))))
1409} 1410}
1410 1411
1411// orgWriter overrides go-org's autolink rendering. go-org ends a bare URL 1412// orgWriter overrides go-org's autolink rendering. go-org ends a bare URL
1412// at the first character outside RFC 3986's set, and that set includes 1413// at the first character outside RFC 3986's set, and that set includes
1413// `.`, `,` and `)`, so a URL closing a sentence or a parenthesis took the 1414// `.`, `,` and `)`, so a URL closing a sentence or a parenthesis took the
1414// punctuation with it. Org stops a plain link before trailing punctuation 1415// punctuation with it. Org stops a plain link before trailing punctuation
1415// and keeps a `)` only when a `(` inside the link opened it. 1416// and keeps a `)` only when a `(` inside the link opened it. It also
1417// renders LaTeX fragments and blocks (math.go).
1416type orgWriter struct { 1418type orgWriter struct {
1417 *org.HTMLWriter 1419 *org.HTMLWriter
1420 math *mathSlots
1418} 1421}
1419 1422
1420func (w *orgWriter) WriteRegularLink(l org.RegularLink) { 1423func (w *orgWriter) WriteRegularLink(l org.RegularLink) {
internal/texmath/symbols.go added +184
@@ -0,0 +1,184 @@
1package texmath
2
3type sym struct {
4 tag string
5 text string
6 upright bool // mathvariant="normal": TeX sets capital Greek upright
7 fixed bool // stretchy="false": a bracket typed without \left
8 limits bool
9}
10
11func mi(s string) sym { return sym{tag: "mi", text: s} }
12func mo(s string) sym { return sym{tag: "mo", text: s} }
13func upright(s string) sym { return sym{tag: "mi", text: s, upright: true} }
14func bracket(s string) sym { return sym{tag: "mo", text: s, fixed: true} }
15func large(s string) sym { return sym{tag: "mo", text: s, limits: true} }
16
17var symbols = map[string]sym{
18 // Greek
19 "alpha": mi("α"), "beta": mi("β"), "gamma": mi("γ"), "delta": mi("δ"),
20 "epsilon": mi("ϵ"), "varepsilon": mi("ε"), "zeta": mi("ζ"), "eta": mi("η"),
21 "theta": mi("θ"), "vartheta": mi("ϑ"), "iota": mi("ι"), "kappa": mi("κ"),
22 "lambda": mi("λ"), "mu": mi("μ"), "nu": mi("ν"), "xi": mi("ξ"),
23 "omicron": mi("ο"), "pi": mi("π"), "varpi": mi("ϖ"), "rho": mi("ρ"),
24 "varrho": mi("ϱ"), "sigma": mi("σ"), "varsigma": mi("ς"), "tau": mi("τ"),
25 "upsilon": mi("υ"), "phi": mi("ϕ"), "varphi": mi("φ"), "chi": mi("χ"),
26 "psi": mi("ψ"), "omega": mi("ω"),
27 "Gamma": upright("Γ"), "Delta": upright("Δ"), "Theta": upright("Θ"),
28 "Lambda": upright("Λ"), "Xi": upright("Ξ"), "Pi": upright("Π"),
29 "Sigma": upright("Σ"), "Upsilon": upright("Υ"), "Phi": upright("Φ"),
30 "Psi": upright("Ψ"), "Omega": upright("Ω"),
31
32 // Letter-like
33 "infty": mi("∞"), "partial": mi("∂"), "nabla": mi("∇"), "emptyset": mi("∅"),
34 "varnothing": mi("∅"), "hbar": mi("ℏ"), "ell": mi("ℓ"), "Re": mi("ℜ"),
35 "Im": mi("ℑ"), "aleph": mi("ℵ"), "wp": mi("℘"), "imath": mi("ı"), "jmath": mi("ȷ"),
36
37 // Binary operators
38 "pm": mo("±"), "mp": mo("∓"), "times": mo("×"), "div": mo("÷"),
39 "cdot": mo("⋅"), "ast": mo("∗"), "star": mo("⋆"), "circ": mo("∘"),
40 "bullet": mo("∙"), "cap": mo("∩"), "cup": mo("∪"), "setminus": mo("∖"),
41 "wedge": mo("∧"), "land": mo("∧"), "vee": mo("∨"), "lor": mo("∨"),
42 "oplus": mo("⊕"), "ominus": mo("⊖"), "otimes": mo("⊗"), "odot": mo("⊙"),
43 "sqcup": mo("⊔"), "sqcap": mo("⊓"), "dagger": mo("†"), "ddagger": mo("‡"),
44 "lnot": mo("¬"), "neg": mo("¬"),
45
46 // Relations
47 "le": mo("≤"), "leq": mo("≤"), "ge": mo("≥"), "geq": mo("≥"),
48 "ne": mo("≠"), "neq": mo("≠"), "ll": mo("≪"), "gg": mo("≫"),
49 "approx": mo("≈"), "equiv": mo("≡"), "sim": mo("∼"), "simeq": mo("≃"),
50 "cong": mo("≅"), "propto": mo("∝"), "in": mo("∈"), "notin": mo("∉"),
51 "ni": mo("∋"), "subset": mo("⊂"), "supset": mo("⊃"), "subseteq": mo("⊆"),
52 "supseteq": mo("⊇"), "perp": mo("⊥"), "parallel": mo("∥"), "mid": mo("∣"),
53 "vdash": mo("⊢"), "models": mo("⊨"), "coloneqq": mo("≔"), "prec": mo("≺"),
54 "succ": mo("≻"), "preceq": mo("⪯"), "succeq": mo("⪰"), "doteq": mo("≐"),
55
56 // Arrows
57 "to": mo("→"), "rightarrow": mo("→"), "leftarrow": mo("←"), "gets": mo("←"),
58 "leftrightarrow": mo("↔"), "Rightarrow": mo("⇒"), "Leftarrow": mo("⇐"),
59 "Leftrightarrow": mo("⇔"), "implies": mo("⟹"), "impliedby": mo("⟸"),
60 "iff": mo("⟺"), "mapsto": mo("↦"), "longrightarrow": mo("⟶"),
61 "longleftarrow": mo("⟵"), "Longrightarrow": mo("⟹"), "uparrow": mo("↑"),
62 "downarrow": mo("↓"), "hookrightarrow": mo("↪"), "rightharpoonup": mo("⇀"),
63
64 // Logic and sets
65 "forall": mo("∀"), "exists": mo("∃"), "nexists": mo("∄"), "therefore": mo("∴"),
66 "because": mo("∵"), "top": mo("⊤"), "bot": mo("⊥"), "angle": mo("∠"),
67 "prime": mo("′"), "degree": mo("°"),
68
69 // Dots
70 "ldots": mo("…"), "dots": mo("…"), "cdots": mo("⋯"), "vdots": mo("⋮"),
71 "ddots": mo("⋱"),
72
73 // Brackets typed without \left
74 "{": bracket("{"), "}": bracket("}"), "|": bracket("‖"),
75 "langle": bracket("⟨"), "rangle": bracket("⟩"), "lvert": bracket("|"),
76 "rvert": bracket("|"), "lVert": bracket("‖"), "rVert": bracket("‖"),
77 "vert": bracket("|"), "Vert": bracket("‖"), "lfloor": bracket("⌊"),
78 "rfloor": bracket("⌋"), "lceil": bracket("⌈"), "rceil": bracket("⌉"),
79 "lbrace": bracket("{"), "rbrace": bracket("}"), "backslash": mo("\\"),
80
81 // Escaped characters
82 "%": mo("%"), "$": mo("$"), "#": mo("#"), "&": mo("&"), "_": mo("_"),
83
84 // Large operators
85 "sum": large("∑"), "prod": large("∏"), "coprod": large("∐"),
86 "bigcup": large("⋃"), "bigcap": large("⋂"), "bigvee": large("⋁"),
87 "bigwedge": large("⋀"), "bigoplus": large("⨁"), "bigotimes": large("⨂"),
88 "bigsqcup": large("⨆"),
89 "int": mo("∫"), "iint": mo("∬"), "iiint": mo("∭"), "oint": mo("∮"),
90}
91
92// spaces are the spacing commands and their widths.
93var spaces = map[string]string{
94 ",": "0.1667em", "thinspace": "0.1667em",
95 ":": "0.2222em", ">": "0.2222em", "medspace": "0.2222em",
96 ";": "0.2778em", "thickspace": "0.2778em",
97 "!": "-0.1667em", "negthinspace": "-0.1667em",
98 " ": "0.3333em", "enspace": "0.5em",
99 "quad": "1em", "qquad": "2em",
100}
101
102// functions are the named functions, set upright; true takes limits under
103// and over in display style.
104var functions = map[string]bool{
105 "sin": false, "cos": false, "tan": false, "cot": false, "sec": false, "csc": false,
106 "arcsin": false, "arccos": false, "arctan": false, "sinh": false, "cosh": false,
107 "tanh": false, "coth": false, "log": false, "ln": false, "lg": false, "exp": false,
108 "dim": false, "deg": false, "hom": false, "ker": false, "arg": false,
109 "lim": true, "liminf": true, "limsup": true, "max": true, "min": true,
110 "sup": true, "inf": true, "det": true, "gcd": true, "Pr": true,
111}
112
113type accent struct {
114 mark string
115 accent bool // mover accent="true": the mark sits close, like an accent
116 under bool
117}
118
119var accents = map[string]accent{
120 "hat": {mark: "^", accent: true}, "widehat": {mark: "^", accent: true},
121 "tilde": {mark: "~", accent: true}, "widetilde": {mark: "~", accent: true},
122 "bar": {mark: "¯", accent: true}, "overline": {mark: "‾", accent: true},
123 "vec": {mark: "→", accent: true}, "overrightarrow": {mark: "→", accent: true},
124 "dot": {mark: "˙", accent: true}, "ddot": {mark: "¨", accent: true},
125 "acute": {mark: "´", accent: true}, "grave": {mark: "`", accent: true},
126 "breve": {mark: "˘", accent: true}, "check": {mark: "ˇ", accent: true},
127 "overbrace": {mark: "⏞"},
128 "underline": {mark: "_", under: true},
129 "underbrace": {mark: "⏟", under: true},
130}
131
132var fonts = map[string]string{
133 "mathrm": "rm", "mathbf": "bf", "boldsymbol": "bf", "bm": "bf",
134 "mathbb": "bb", "mathcal": "cal", "mathscr": "cal", "mathfrak": "frak",
135 "mathsf": "sf", "mathtt": "tt", "mathit": "it",
136}
137
138var delimiters = map[string]string{
139 "{": "{", "}": "}", "lbrace": "{", "rbrace": "}", "|": "‖",
140 "langle": "⟨", "rangle": "⟩", "lvert": "|", "rvert": "|", "vert": "|",
141 "lVert": "‖", "rVert": "‖", "Vert": "‖", "lfloor": "⌊", "rfloor": "⌋",
142 "lceil": "⌈", "rceil": "⌉", "backslash": "\\",
143}
144
145// alphabet is where a font's capitals, small letters and digits start in
146// Mathematical Alphanumeric Symbols (0 where the font has none), plus the
147// letters Unicode had already encoded elsewhere and left as holes.
148type alphabet struct {
149 upper, lower, digit rune
150 holes map[rune]rune
151}
152
153var alphabets = map[string]alphabet{
154 "bf": {0x1D400, 0x1D41A, 0x1D7CE, nil},
155 "it": {0x1D434, 0x1D44E, 0, map[rune]rune{'h': 'ℎ'}},
156 "bb": {0x1D538, 0x1D552, 0x1D7D8, map[rune]rune{
157 'C': 'ℂ', 'H': 'ℍ', 'N': 'ℕ', 'P': 'ℙ', 'Q': 'ℚ', 'R': 'ℝ', 'Z': 'ℤ'}},
158 "cal": {0x1D49C, 0x1D4B6, 0, map[rune]rune{
159 'B': 'ℬ', 'E': 'ℰ', 'F': 'ℱ', 'H': 'ℋ', 'I': 'ℐ', 'L': 'ℒ', 'M': 'ℳ',
160 'R': 'ℛ', 'e': 'ℯ', 'g': 'ℊ', 'o': 'ℴ'}},
161 "frak": {0x1D504, 0x1D51E, 0, map[rune]rune{
162 'C': 'ℭ', 'H': 'ℌ', 'I': 'ℑ', 'R': 'ℜ', 'Z': 'ℨ'}},
163 "sf": {0x1D5A0, 0x1D5BA, 0x1D7E2, nil},
164 "tt": {0x1D670, 0x1D68A, 0x1D7F6, nil},
165}
166
167func alphanumeric(font string, r rune) rune {
168 a, ok := alphabets[font]
169 if !ok {
170 return r
171 }
172 if h, ok := a.holes[r]; ok {
173 return h
174 }
175 switch {
176 case r >= 'A' && r <= 'Z':
177 return a.upper + r - 'A'
178 case r >= 'a' && r <= 'z':
179 return a.lower + r - 'a'
180 case r >= '0' && r <= '9' && a.digit != 0:
181 return a.digit + r - '0'
182 }
183 return r
184}
internal/texmath/testdata/fuzz/FuzzConvert/1c174b99741b4473 added +3
@@ -0,0 +1,3 @@
1go test fuzz v1
2string("\\operatorname{\x00}")
3bool(false)
internal/texmath/texmath.go added +759
@@ -0,0 +1,759 @@
1// Package texmath converts a subset of TeX math to MathML.
2//
3// The subset: letters, numbers and operator characters; ^ and _; braces;
4// \frac and friends, \binom, \sqrt with an optional index; Greek letters and
5// the common symbols, relations and arrows in symbols.go; function names
6// (\sin, \lim, \operatorname{...}); large operators, which take their limits
7// above and below in display style; \left, \middle and \right; accents,
8// \overline, \underline and the braces; spacing commands; \text; the font
9// commands \mathrm, \mathbf, \mathbb, \mathcal, \mathscr, \mathfrak, \mathsf,
10// \mathtt, \mathit, \boldsymbol; and the environments matrix, pmatrix,
11// bmatrix, Bmatrix, vmatrix, Vmatrix, smallmatrix, cases, aligned, align,
12// gathered, gather, split, equation and displaymath.
13//
14// Anything else, including every command that defines macros, sets colors or
15// styles, or links, is an error; the caller shows the source instead. The
16// output uses only the elements and attributes listed in Elements and Attrs,
17// and its size is linear in the input, which is capped at MaxInput bytes and
18// MaxDepth levels of nesting.
19package texmath
20
21import (
22 "errors"
23 "fmt"
24 "html"
25 "strings"
26 "unicode"
27 "unicode/utf8"
28)
29
30// Limits on one expression.
31const (
32 MaxInput = 8 << 10
33 MaxDepth = 64
34)
35
36// Elements is every MathML element Convert emits.
37var Elements = []string{
38 "math", "mrow", "mi", "mn", "mo", "mtext", "mspace",
39 "mfrac", "msqrt", "mroot", "msub", "msup", "msubsup",
40 "munder", "mover", "munderover", "mtable", "mtr", "mtd",
41}
42
43// Attrs is every attribute Convert emits, by element, with the values it
44// can take: a fixed value, or "<length>" for an em length.
45var Attrs = map[string]map[string]string{
46 "math": {"display": "block"},
47 "mi": {"mathvariant": "normal"},
48 "mo": {"stretchy": "false"},
49 "mfrac": {"linethickness": "0"},
50 "mover": {"accent": "true"},
51 "mspace": {"width": "<length>"},
52}
53
54var (
55 ErrTooLong = errors.New("texmath: expression too long")
56 ErrTooDeep = errors.New("texmath: expression nested too deeply")
57)
58
59// Convert renders tex as one <math> element; display selects block layout
60// and display-style limits.
61func Convert(tex string, display bool) (string, error) {
62 if len(tex) > MaxInput {
63 return "", ErrTooLong
64 }
65 if !utf8.ValidString(tex) {
66 return "", errors.New("texmath: invalid UTF-8")
67 }
68 for _, r := range tex {
69 if r < 0x20 && r != '\t' && r != '\n' && r != '\r' || r == 0x7F || r == 0xFFFE || r == 0xFFFF {
70 return "", errors.New("texmath: control character")
71 }
72 }
73 p := &parser{src: tex, display: display}
74 kids, err := p.list(func(t token) bool { return false })
75 if err != nil {
76 return "", err
77 }
78 if t := p.peek(); t.kind != eof {
79 return "", p.unexpected(t)
80 }
81 root := &node{tag: "math", kids: kids}
82 if display {
83 root.attr("display", "block")
84 }
85 var b strings.Builder
86 root.write(&b)
87 return b.String(), nil
88}
89
90type node struct {
91 tag string
92 attrs [][2]string
93 text string
94 kids []*node
95 limits bool // a large operator: limits go under and over in display style
96 fn bool // a function name: a thin space follows unless a delimiter does
97}
98
99func (n *node) attr(k, v string) *node {
100 n.attrs = append(n.attrs, [2]string{k, v})
101 return n
102}
103
104func (n *node) write(b *strings.Builder) {
105 b.WriteByte('<')
106 b.WriteString(n.tag)
107 for _, a := range n.attrs {
108 b.WriteString(" " + a[0] + `="` + html.EscapeString(a[1]) + `"`)
109 }
110 b.WriteByte('>')
111 b.WriteString(html.EscapeString(n.text))
112 for _, k := range n.kids {
113 k.write(b)
114 }
115 b.WriteString("</" + n.tag + ">")
116}
117
118func el(tag string, kids ...*node) *node { return &node{tag: tag, kids: kids} }
119func leaf(tag, text string) *node { return &node{tag: tag, text: text} }
120func row(kids []*node) *node { return &node{tag: "mrow", kids: kids} }
121func space(w string) *node { return (&node{tag: "mspace"}).attr("width", w) }
122
123// fixed is an operator that must not stretch to the height of its row, the
124// way a bracket typed without \left does not in TeX.
125func fixed(s string) *node { return leaf("mo", s).attr("stretchy", "false") }
126
127type kind int
128
129const (
130 eof kind = iota
131 char // one character
132 cmd // a control sequence; val is its name without the backslash
133)
134
135type token struct {
136 kind kind
137 val string
138 pos int
139}
140
141type parser struct {
142 src string
143 pos int
144 depth int
145 display bool
146 font string
147}
148
149func (p *parser) skip() {
150 for p.pos < len(p.src) {
151 c := p.src[p.pos]
152 switch {
153 case c == ' ' || c == '\t' || c == '\n' || c == '\r':
154 p.pos++
155 case c == '%':
156 for p.pos < len(p.src) && p.src[p.pos] != '\n' {
157 p.pos++
158 }
159 default:
160 return
161 }
162 }
163}
164
165func (p *parser) peek() token {
166 save := p.pos
167 t := p.next()
168 p.pos = save
169 return t
170}
171
172func (p *parser) next() token {
173 p.skip()
174 start := p.pos
175 if p.pos >= len(p.src) {
176 return token{kind: eof, pos: start}
177 }
178 if p.src[p.pos] == '\\' {
179 p.pos++
180 if p.pos >= len(p.src) {
181 return token{kind: cmd, val: "", pos: start}
182 }
183 if isASCIILetter(p.src[p.pos]) {
184 end := p.pos
185 for end < len(p.src) && isASCIILetter(p.src[end]) {
186 end++
187 }
188 name := p.src[p.pos:end]
189 p.pos = end
190 // \operatorname* and friends: the star is part of the name.
191 if p.pos < len(p.src) && p.src[p.pos] == '*' && starred[name] {
192 p.pos++
193 name += "*"
194 }
195 return token{kind: cmd, val: name, pos: start}
196 }
197 r, n := utf8.DecodeRuneInString(p.src[p.pos:])
198 p.pos += n
199 return token{kind: cmd, val: string(r), pos: start}
200 }
201 r, n := utf8.DecodeRuneInString(p.src[p.pos:])
202 p.pos += n
203 return token{kind: char, val: string(r), pos: start}
204}
205
206var starred = map[string]bool{"operatorname": true}
207
208func isASCIILetter(c byte) bool { return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' }
209
210func (p *parser) unexpected(t token) error {
211 switch t.kind {
212 case eof:
213 return errors.New("texmath: unexpected end of input")
214 case cmd:
215 return fmt.Errorf(`texmath: unexpected \%s at %d`, t.val, t.pos)
216 }
217 return fmt.Errorf("texmath: unexpected %q at %d", t.val, t.pos)
218}
219
220func (p *parser) expect(kind kind, val string) error {
221 if t := p.next(); t.kind != kind || t.val != val {
222 return p.unexpected(t)
223 }
224 return nil
225}
226
227func isChar(t token, s string) bool { return t.kind == char && t.val == s }
228func isCmd(t token, s string) bool { return t.kind == cmd && t.val == s }
229
230// list parses atoms until the input ends or stop matches the next token,
231// which is left unread.
232func (p *parser) list(stop func(token) bool) ([]*node, error) {
233 var out []*node
234 for {
235 t := p.peek()
236 if t.kind == eof || stop(t) {
237 return out, nil
238 }
239 n, err := p.scripted()
240 if err != nil {
241 return nil, err
242 }
243 if n == nil {
244 continue
245 }
246 out = append(out, n)
247 if n.fn && !p.delimiterNext() {
248 out = append(out, space("0.1667em"))
249 }
250 }
251}
252
253// delimiterNext reports whether the next token closes a group or opens a
254// bracket, where TeX puts no space after a function name.
255func (p *parser) delimiterNext() bool {
256 t := p.peek()
257 if t.kind == eof {
258 return true
259 }
260 if t.kind == char {
261 return strings.Contains("()[]{}|&.,;", t.val)
262 }
263 switch t.val {
264 case "left", "right", "\\", "end", ",", ";", "!", "quad", "qquad", "{", "}":
265 return true
266 }
267 return false
268}
269
270// scripted parses one atom and any sub- and superscripts attached to it.
271func (p *parser) scripted() (*node, error) {
272 var base *node
273 if t := p.peek(); !isChar(t, "^") && !isChar(t, "_") {
274 var err error
275 if base, err = p.atom(); err != nil {
276 return nil, err
277 }
278 }
279 var sub, sup *node
280 limits := base != nil && base.limits && p.display
281 for {
282 t := p.peek()
283 switch {
284 case isCmd(t, "limits"):
285 p.next()
286 limits = base != nil && base.limits
287 continue
288 case isCmd(t, "nolimits"):
289 p.next()
290 limits = false
291 continue
292 case isChar(t, "^"), isChar(t, "_"):
293 default:
294 if sub == nil && sup == nil {
295 return base, nil
296 }
297 if base == nil {
298 base = row(nil)
299 }
300 return script(base, sub, sup, limits), nil
301 }
302 p.next()
303 arg, err := p.arg()
304 if err != nil {
305 return nil, err
306 }
307 if t.val == "^" {
308 if sup != nil {
309 return nil, fmt.Errorf("texmath: double superscript at %d", t.pos)
310 }
311 sup = arg
312 } else {
313 if sub != nil {
314 return nil, fmt.Errorf("texmath: double subscript at %d", t.pos)
315 }
316 sub = arg
317 }
318 }
319}
320
321func script(base, sub, sup *node, limits bool) *node {
322 fn := base.fn
323 var n *node
324 switch {
325 case limits && sub != nil && sup != nil:
326 n = el("munderover", base, sub, sup)
327 case limits && sub != nil:
328 n = el("munder", base, sub)
329 case limits:
330 n = el("mover", base, sup)
331 case sub != nil && sup != nil:
332 n = el("msubsup", base, sub, sup)
333 case sub != nil:
334 n = el("msub", base, sub)
335 default:
336 n = el("msup", base, sup)
337 }
338 n.fn = fn
339 return n
340}
341
342// arg parses a command's argument: a braced group, or else one token.
343func (p *parser) arg() (*node, error) {
344 t := p.peek()
345 switch {
346 case t.kind == eof:
347 return nil, p.unexpected(t)
348 case t.kind == char && strings.Contains("}&^_", t.val):
349 return nil, p.unexpected(t)
350 case t.kind == char && t.val >= "0" && t.val <= "9":
351 p.next()
352 return leaf("mn", p.styled(t.val)), nil
353 }
354 n, err := p.atom()
355 if err == nil && n == nil {
356 // \displaystyle and the like render nothing, so cannot be an argument.
357 return nil, fmt.Errorf(`texmath: \%s cannot be an argument at %d`, t.val, t.pos)
358 }
359 return n, err
360}
361
362func (p *parser) enter() error {
363 p.depth++
364 if p.depth > MaxDepth {
365 return ErrTooDeep
366 }
367 return nil
368}
369
370// atom parses one atom. A nil node with a nil error is a command that
371// renders nothing, such as \displaystyle.
372func (p *parser) atom() (*node, error) {
373 if err := p.enter(); err != nil {
374 return nil, err
375 }
376 defer func() { p.depth-- }()
377 t := p.next()
378 switch t.kind {
379 case eof:
380 return nil, p.unexpected(t)
381 case char:
382 return p.charAtom(t)
383 }
384 return p.command(t)
385}
386
387func (p *parser) charAtom(t token) (*node, error) {
388 r, _ := utf8.DecodeRuneInString(t.val)
389 switch {
390 case t.val == "{":
391 font := p.font
392 kids, err := p.list(func(t token) bool { return isChar(t, "}") })
393 p.font = font
394 if err != nil {
395 return nil, err
396 }
397 if err := p.expect(char, "}"); err != nil {
398 return nil, err
399 }
400 return row(kids), nil
401 case r >= '0' && r <= '9':
402 num := t.val
403 for p.pos < len(p.src) {
404 c := p.src[p.pos]
405 if c >= '0' && c <= '9' || c == '.' && p.pos+1 < len(p.src) && p.src[p.pos+1] >= '0' && p.src[p.pos+1] <= '9' {
406 num += string(c)
407 p.pos++
408 continue
409 }
410 break
411 }
412 return leaf("mn", p.styled(num)), nil
413 case unicode.IsLetter(r):
414 n := leaf("mi", p.styled(t.val))
415 if p.font == "rm" {
416 n.attr("mathvariant", "normal")
417 }
418 return n, nil
419 }
420 switch t.val {
421 case "}", "&", "$", "#", "\\":
422 return nil, p.unexpected(t)
423 case "(", ")", "[", "]", "|", "/":
424 return fixed(t.val), nil
425 case "-":
426 return leaf("mo", "−"), nil
427 case "*":
428 return leaf("mo", "∗"), nil
429 case "'":
430 return leaf("mo", "′"), nil
431 case "~":
432 return space("0.3333em"), nil
433 }
434 if r < 0x20 || r == utf8.RuneError {
435 return nil, p.unexpected(t)
436 }
437 return leaf("mo", t.val), nil
438}
439
440func (p *parser) command(t token) (*node, error) {
441 name := t.val
442 if s, ok := symbols[name]; ok {
443 n := leaf(s.tag, s.text)
444 if s.upright {
445 n.attr("mathvariant", "normal")
446 }
447 if s.fixed {
448 n.attr("stretchy", "false")
449 }
450 n.limits = s.limits
451 return n, nil
452 }
453 if w, ok := spaces[name]; ok {
454 return space(w), nil
455 }
456 if f, ok := functions[name]; ok {
457 n := leaf("mi", name)
458 n.limits, n.fn = f, true
459 return n, nil
460 }
461 if a, ok := accents[name]; ok {
462 arg, err := p.arg()
463 if err != nil {
464 return nil, err
465 }
466 if a.under {
467 return el("munder", arg, leaf("mo", a.mark)), nil
468 }
469 n := el("mover", arg, leaf("mo", a.mark))
470 if a.accent {
471 n.attr("accent", "true")
472 }
473 return n, nil
474 }
475 if f, ok := fonts[name]; ok {
476 font := p.font
477 p.font = f
478 arg, err := p.arg()
479 p.font = font
480 return arg, err
481 }
482 switch name {
483 case "frac", "dfrac", "tfrac", "cfrac":
484 num, err := p.arg()
485 if err != nil {
486 return nil, err
487 }
488 den, err := p.arg()
489 if err != nil {
490 return nil, err
491 }
492 return el("mfrac", num, den), nil
493 case "binom", "dbinom", "tbinom":
494 top, err := p.arg()
495 if err != nil {
496 return nil, err
497 }
498 bottom, err := p.arg()
499 if err != nil {
500 return nil, err
501 }
502 frac := el("mfrac", top, bottom).attr("linethickness", "0")
503 return row([]*node{leaf("mo", "("), frac, leaf("mo", ")")}), nil
504 case "sqrt":
505 var index *node
506 if isChar(p.peek(), "[") {
507 p.next()
508 kids, err := p.list(func(t token) bool { return isChar(t, "]") })
509 if err != nil {
510 return nil, err
511 }
512 if err := p.expect(char, "]"); err != nil {
513 return nil, err
514 }
515 index = row(kids)
516 }
517 arg, err := p.arg()
518 if err != nil {
519 return nil, err
520 }
521 if index != nil {
522 return el("mroot", arg, index), nil
523 }
524 return el("msqrt", arg), nil
525 case "left":
526 open, err := p.delimiter()
527 if err != nil {
528 return nil, err
529 }
530 kids, err := p.list(func(t token) bool { return isCmd(t, "right") })
531 if err != nil {
532 return nil, err
533 }
534 if err := p.expect(cmd, "right"); err != nil {
535 return nil, err
536 }
537 cls, err := p.delimiter()
538 if err != nil {
539 return nil, err
540 }
541 var out []*node
542 if open != "" {
543 out = append(out, leaf("mo", open))
544 }
545 out = append(out, kids...)
546 if cls != "" {
547 out = append(out, leaf("mo", cls))
548 }
549 return row(out), nil
550 case "middle":
551 d, err := p.delimiter()
552 if err != nil {
553 return nil, err
554 }
555 return leaf("mo", d), nil
556 case "text", "textrm", "textnormal", "mbox":
557 s, err := p.rawGroup()
558 if err != nil {
559 return nil, err
560 }
561 return leaf("mtext", s), nil
562 case "operatorname", "operatorname*":
563 s, err := p.rawGroup()
564 if err != nil {
565 return nil, err
566 }
567 n := leaf("mi", s)
568 if utf8.RuneCountInString(s) == 1 {
569 n.attr("mathvariant", "normal")
570 }
571 n.limits, n.fn = name == "operatorname*", true
572 return n, nil
573 case "begin":
574 return p.environment()
575 case "displaystyle", "textstyle", "scriptstyle", "scriptscriptstyle", "limits", "nolimits":
576 return nil, nil
577 }
578 return nil, p.unexpected(t)
579}
580
581// rawGroup reads a braced argument as text, for \text and \operatorname.
582// Nested braces must balance; \{, \}, \$, \%, \&, \#, \_ and \\ stand for
583// the character.
584func (p *parser) rawGroup() (string, error) {
585 if err := p.expect(char, "{"); err != nil {
586 return "", err
587 }
588 var b strings.Builder
589 depth := 0
590 for p.pos < len(p.src) {
591 c := p.src[p.pos]
592 switch {
593 case c == '\\' && p.pos+1 < len(p.src) && strings.IndexByte(`{}$%&#_\`, p.src[p.pos+1]) >= 0:
594 b.WriteByte(p.src[p.pos+1])
595 p.pos += 2
596 continue
597 case c == '{':
598 depth++
599 case c == '}':
600 if depth == 0 {
601 p.pos++
602 return b.String(), nil
603 }
604 depth--
605 }
606 b.WriteByte(c)
607 p.pos++
608 }
609 return "", errors.New("texmath: unterminated group")
610}
611
612// delimiter reads what follows \left, \middle or \right; "." is none.
613func (p *parser) delimiter() (string, error) {
614 t := p.next()
615 if t.kind == char {
616 switch t.val {
617 case ".":
618 return "", nil
619 case "<":
620 return "⟨", nil
621 case ">":
622 return "⟩", nil
623 case "(", ")", "[", "]", "|", "/":
624 return t.val, nil
625 }
626 }
627 if t.kind == cmd {
628 if d, ok := delimiters[t.val]; ok {
629 return d, nil
630 }
631 }
632 return "", p.unexpected(t)
633}
634
635type env struct {
636 open, close string
637 table bool
638}
639
640var environments = map[string]env{
641 "matrix": {table: true},
642 "smallmatrix": {table: true},
643 "pmatrix": {"(", ")", true},
644 "bmatrix": {"[", "]", true},
645 "Bmatrix": {"{", "}", true},
646 "vmatrix": {"|", "|", true},
647 "Vmatrix": {"‖", "‖", true},
648 "cases": {"{", "", true},
649 "aligned": {table: true},
650 "align": {table: true},
651 "align*": {table: true},
652 "gathered": {table: true},
653 "gather": {table: true},
654 "gather*": {table: true},
655 "split": {table: true},
656 "equation": {},
657 "equation*": {},
658 "displaymath": {},
659}
660
661func (p *parser) envName() (string, error) {
662 if err := p.expect(char, "{"); err != nil {
663 return "", err
664 }
665 end := strings.IndexByte(p.src[p.pos:], '}')
666 if end < 0 {
667 return "", errors.New("texmath: unterminated environment name")
668 }
669 name := p.src[p.pos : p.pos+end]
670 p.pos += end + 1
671 return name, nil
672}
673
674func (p *parser) environment() (*node, error) {
675 name, err := p.envName()
676 if err != nil {
677 return nil, err
678 }
679 e, ok := environments[name]
680 if !ok {
681 return nil, fmt.Errorf("texmath: unsupported environment %q", name)
682 }
683 var body *node
684 if e.table {
685 body, err = p.table()
686 } else {
687 var kids []*node
688 kids, err = p.list(func(t token) bool { return isCmd(t, "end") })
689 body = row(kids)
690 }
691 if err != nil {
692 return nil, err
693 }
694 if err := p.expect(cmd, "end"); err != nil {
695 return nil, err
696 }
697 if end, err := p.envName(); err != nil {
698 return nil, err
699 } else if end != name {
700 return nil, fmt.Errorf(`texmath: \begin{%s} ended by \end{%s}`, name, end)
701 }
702 if e.open == "" && e.close == "" {
703 return body, nil
704 }
705 out := []*node{}
706 if e.open != "" {
707 out = append(out, leaf("mo", e.open))
708 }
709 out = append(out, body)
710 if e.close != "" {
711 out = append(out, leaf("mo", e.close))
712 }
713 return row(out), nil
714}
715
716// table parses rows separated by \\ and cells separated by &, up to \end.
717func (p *parser) table() (*node, error) {
718 stop := func(t token) bool { return isChar(t, "&") || isCmd(t, "\\") || isCmd(t, "end") }
719 tbl := el("mtable")
720 cur := el("mtr")
721 for {
722 kids, err := p.list(stop)
723 if err != nil {
724 return nil, err
725 }
726 cur.kids = append(cur.kids, el("mtd", kids...))
727 t := p.peek()
728 switch {
729 case isChar(t, "&"):
730 p.next()
731 case isCmd(t, "\\"):
732 p.next()
733 tbl.kids = append(tbl.kids, cur)
734 cur = el("mtr")
735 case isCmd(t, "end"):
736 // A trailing \\ leaves one empty cell; it is not a row.
737 if len(cur.kids) > 1 || len(cur.kids[0].kids) > 0 {
738 tbl.kids = append(tbl.kids, cur)
739 }
740 return tbl, nil
741 default:
742 return nil, p.unexpected(t)
743 }
744 }
745}
746
747// styled maps letters and digits into the current font's Mathematical
748// Alphanumeric Symbols, which is how MathML Core spells \mathbb and the rest:
749// mathvariant is honoured only as "normal".
750func (p *parser) styled(s string) string {
751 if p.font == "" || p.font == "rm" {
752 return s
753 }
754 var b strings.Builder
755 for _, r := range s {
756 b.WriteRune(alphanumeric(p.font, r))
757 }
758 return b.String()
759}
internal/texmath/texmath_test.go added +224
@@ -0,0 +1,224 @@
1package texmath
2
3import (
4 "encoding/xml"
5 "errors"
6 "io"
7 "regexp"
8 "strings"
9 "testing"
10 "time"
11)
12
13func TestConvert(t *testing.T) {
14 cases := []struct {
15 tex, want string
16 }{
17 {`x`, `<math><mi>x</mi></math>`},
18 {`12.5+x`, `<math><mn>12.5</mn><mo>+</mo><mi>x</mi></math>`},
19 {`a-b`, `<math><mi>a</mi><mo>−</mo><mi>b</mi></math>`},
20 {`x^2`, `<math><msup><mi>x</mi><mn>2</mn></msup></math>`},
21 {`x_i^2`, `<math><msubsup><mi>x</mi><mi>i</mi><mn>2</mn></msubsup></math>`},
22 {`x^{2n}`, `<math><msup><mi>x</mi><mrow><mn>2</mn><mi>n</mi></mrow></msup></math>`},
23 {`\frac{a}{b}`, `<math><mfrac><mrow><mi>a</mi></mrow><mrow><mi>b</mi></mrow></mfrac></math>`},
24 {`\frac12`, `<math><mfrac><mn>1</mn><mn>2</mn></mfrac></math>`},
25 {`\sqrt{x}`, `<math><msqrt><mrow><mi>x</mi></mrow></msqrt></math>`},
26 {`\sqrt[3]{x}`, `<math><mroot><mrow><mi>x</mi></mrow><mrow><mn>3</mn></mrow></mroot></math>`},
27 {`\alpha\Gamma`, `<math><mi>α</mi><mi mathvariant="normal">Γ</mi></math>`},
28 {`a\le b`, `<math><mi>a</mi><mo>≤</mo><mi>b</mi></math>`},
29 {`(a)`, `<math><mo stretchy="false">(</mo><mi>a</mi><mo stretchy="false">)</mo></math>`},
30 {`\left( x \right.`, `<math><mrow><mo>(</mo><mi>x</mi></mrow></math>`},
31 {`\left\{ x \middle| y \right\}`, `<math><mrow><mo>{</mo><mi>x</mi><mo>|</mo><mi>y</mi><mo>}</mo></mrow></math>`},
32 {`\sin x`, `<math><mi>sin</mi><mspace width="0.1667em"></mspace><mi>x</mi></math>`},
33 {`\sin(x)`, `<math><mi>sin</mi><mo stretchy="false">(</mo><mi>x</mi><mo stretchy="false">)</mo></math>`},
34 {`\text{if } x`, `<math><mtext>if </mtext><mi>x</mi></math>`},
35 {`\mathbb{R}\mathbf{v}`, `<math><mrow><mi>ℝ</mi></mrow><mrow><mi>𝐯</mi></mrow></math>`},
36 {`\mathrm{d}x`, `<math><mrow><mi mathvariant="normal">d</mi></mrow><mi>x</mi></math>`},
37 {`\hat{x}`, `<math><mover accent="true"><mrow><mi>x</mi></mrow><mo>^</mo></mover></math>`},
38 {`a\,b`, `<math><mi>a</mi><mspace width="0.1667em"></mspace><mi>b</mi></math>`},
39 {`\binom{n}{k}`, `<math><mrow><mo>(</mo><mfrac linethickness="0"><mrow><mi>n</mi></mrow><mrow><mi>k</mi></mrow></mfrac><mo>)</mo></mrow></math>`},
40 {`\begin{pmatrix}a&b\\c&d\end{pmatrix}`, `<math><mrow><mo>(</mo><mtable><mtr><mtd><mi>a</mi></mtd><mtd><mi>b</mi></mtd></mtr><mtr><mtd><mi>c</mi></mtd><mtd><mi>d</mi></mtd></mtr></mtable><mo>)</mo></mrow></math>`},
41 {`\begin{matrix}a\\\end{matrix}`, `<math><mtable><mtr><mtd><mi>a</mi></mtd></mtr></mtable></math>`},
42 {`x % comment` + "\n" + `+1`, `<math><mi>x</mi><mo>+</mo><mn>1</mn></math>`},
43 {`a<b`, `<math><mi>a</mi><mo>&lt;</mo><mi>b</mi></math>`},
44 }
45 for _, c := range cases {
46 got, err := Convert(c.tex, false)
47 if err != nil {
48 t.Errorf("Convert(%q): %v", c.tex, err)
49 continue
50 }
51 if got != c.want {
52 t.Errorf("Convert(%q)\n got %s\nwant %s", c.tex, got, c.want)
53 }
54 }
55}
56
57func TestConvertDisplayLimits(t *testing.T) {
58 got, err := Convert(`\sum_{i=1}^n i`, true)
59 if err != nil {
60 t.Fatal(err)
61 }
62 if !strings.HasPrefix(got, `<math display="block"><munderover><mo>∑</mo>`) {
63 t.Errorf("display sum: %s", got)
64 }
65 got, _ = Convert(`\sum_{i=1}^n i`, false)
66 if !strings.HasPrefix(got, `<math><msubsup><mo>∑</mo>`) {
67 t.Errorf("inline sum: %s", got)
68 }
69 got, _ = Convert(`\lim_{x\to 0} f`, true)
70 if !strings.HasPrefix(got, `<math display="block"><munder><mi>lim</mi>`) {
71 t.Errorf("display lim: %s", got)
72 }
73}
74
75func TestConvertRefuses(t *testing.T) {
76 for _, tex := range []string{
77 `\frac{a}`, `x^`, `{x`, `x}`, `x^1^2`, `\left( x`, `\right)`,
78 `\begin{pmatrix}a\end{bmatrix}`, `\begin{tabular}x\end{tabular}`,
79 `a & b`, `a \\ b`, `\unknown`,
80 `\href{javascript:alert(1)}{x}`, `\url{javascript:alert(1)}`,
81 `\style{color:red}{x}`, `\color{red}{x}`, `\class{a}{x}`, `\htmlId{a}{x}`,
82 `\def\a{x}\a`, `\newcommand{\a}{x}`, `\require{html}`, `\unicode{x}`,
83 `\includegraphics{x}`, `\input{/etc/passwd}`,
84 `\sqrt\displaystyle`, `\frac\displaystyle y`, `\hat\displaystyle`,
85 `\overbrace\displaystyle`, `\binom\displaystyle1`, `\mathbf\limits`, `x^\nolimits`,
86 } {
87 if out, err := Convert(tex, false); err == nil {
88 t.Errorf("Convert(%q) = %s, want an error", tex, out)
89 }
90 }
91}
92
93func TestConvertBounds(t *testing.T) {
94 deep := strings.Repeat("{", 100000) + "x" + strings.Repeat("}", 100000)
95 fracs := strings.Repeat(`\frac{`, MaxDepth+1) + "x"
96 for _, tex := range []string{deep, fracs, strings.Repeat("x", MaxInput+1)} {
97 start := time.Now()
98 if _, err := Convert(tex, false); err == nil {
99 t.Errorf("Convert(%.20q...) succeeded", tex)
100 }
101 if d := time.Since(start); d > time.Second {
102 t.Errorf("Convert(%.20q...) took %v", tex, d)
103 }
104 }
105 // The largest accepted input: output stays linear in it.
106 tex := strings.Repeat(`{}`, MaxInput/2)
107 start := time.Now()
108 out, err := Convert(tex, false)
109 if err != nil {
110 t.Fatal(err)
111 }
112 if len(out) > 16*MaxInput {
113 t.Errorf("output %d bytes for %d bytes of input", len(out), len(tex))
114 }
115 if d := time.Since(start); d > time.Second {
116 t.Errorf("took %v", d)
117 }
118}
119
120var tagAttr = regexp.MustCompile(`<([a-z]+)((?: [a-z]+="[^"]*")*)>`)
121var attrPair = regexp.MustCompile(` ([a-z]+)="([^"]*)"`)
122
123// Every element and attribute in the output is one Elements and Attrs name,
124// so the sanitizer's allowlist built from them is complete.
125func TestConvertEmitsOnlyListed(t *testing.T) {
126 allowed := map[string]bool{}
127 for _, e := range Elements {
128 allowed[e] = true
129 }
130 var inputs []string
131 for name := range symbols {
132 inputs = append(inputs, `\`+name)
133 }
134 for name := range functions {
135 inputs = append(inputs, `\`+name+`_a^b x`)
136 }
137 for name := range accents {
138 inputs = append(inputs, `\`+name+`{x}`)
139 }
140 for name := range spaces {
141 inputs = append(inputs, `a\`+name+` b`)
142 }
143 for name := range fonts {
144 inputs = append(inputs, `\`+name+`{Ab1}`)
145 }
146 for name := range environments {
147 inputs = append(inputs, `\begin{`+name+`}a&b\\c&d\end{`+name+`}`)
148 }
149 inputs = append(inputs, `\binom12`, `\sqrt[3]{x}`, `\left(\middle|\right)`, `\text{a}`,
150 `\operatorname{rank}A`, `\operatorname*{arg\,max}_x`, `x_1^2`, `\sum_1^2`, `\sum_1`, `\sum^2`, `(a)~'`)
151 for _, in := range inputs {
152 for _, display := range []bool{false, true} {
153 out, err := Convert(in, display)
154 if err != nil {
155 if strings.HasPrefix(in, `\begin{`) {
156 continue // equation and friends take no & or \\
157 }
158 t.Errorf("Convert(%q): %v", in, err)
159 continue
160 }
161 for _, m := range tagAttr.FindAllStringSubmatch(out, -1) {
162 if !allowed[m[1]] {
163 t.Errorf("%q emits <%s>", in, m[1])
164 }
165 for _, a := range attrPair.FindAllStringSubmatch(m[2], -1) {
166 want, ok := Attrs[m[1]][a[1]]
167 if !ok || (want != "<length>" && want != a[2]) {
168 t.Errorf("%q emits %s %s=%q", in, m[1], a[1], a[2])
169 }
170 }
171 }
172 }
173 }
174}
175
176// FuzzConvert: no panic, output bounded by the input, and output that is
177// well-formed XML using only the listed elements and attribute values.
178func FuzzConvert(f *testing.F) {
179 for _, seed := range []string{
180 `x^2`, `\frac{a}{b}`, `\sqrt[3]{x}`, `\left(\frac12\right)`, `\sum_{i=1}^n i`,
181 `\begin{pmatrix}a&b\\c&d\end{pmatrix}`, `\text{a<b}`, `\mathbb{R}`, `\hat{x}~'`,
182 `\sqrt\displaystyle`, `\operatorname*{argmax}_x`, `{{{x}}}`, `a\,b\quad c`,
183 } {
184 f.Add(seed, false)
185 }
186 allowed := map[string]bool{}
187 for _, e := range Elements {
188 allowed[e] = true
189 }
190 length := regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?em$`)
191 f.Fuzz(func(t *testing.T, tex string, display bool) {
192 out, err := Convert(tex, display)
193 if err != nil {
194 return
195 }
196 if len(out) > 64*len(tex)+256 {
197 t.Fatalf("%d bytes out for %d in", len(out), len(tex))
198 }
199 d := xml.NewDecoder(strings.NewReader(out))
200 for {
201 tok, err := d.Token()
202 if errors.Is(err, io.EOF) {
203 break
204 }
205 if err != nil {
206 t.Fatalf("not XML: %v\n%s", err, out)
207 }
208 start, ok := tok.(xml.StartElement)
209 if !ok {
210 continue
211 }
212 if !allowed[start.Name.Local] || start.Name.Space != "" {
213 t.Fatalf("element %v in %s", start.Name, out)
214 }
215 for _, a := range start.Attr {
216 want, ok := Attrs[start.Name.Local][a.Name.Local]
217 if !ok || a.Name.Space != "" || (want == "<length>" && !length.MatchString(a.Value)) ||
218 (want != "<length>" && want != a.Value) {
219 t.Fatalf("attribute %v=%q on %s in %s", a.Name, a.Value, start.Name.Local, out)
220 }
221 }
222 }
223 })
224}