Commit ebab9139d1

ebab9139d10c73335279b5e9b9f8b401b73caee7

parent: 58b796baf9

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-29 05:41 UTC

web: linear inline math scan, per-document math budget, own math policy

Closes #294

Layout: unified · split

.gitbay/wiki/Users.org +6 −2
@@ -311,8 +311,12 @@ numbers, operators, scripts, =\frac=, =\sqrt=, Greek and common symbols,
311311=\left=/=\right=, accents, =\text=, the =\math…= fonts, matrices,
312312=cases= and =aligned=; the full list heads =internal/texmath/texmath.go=).
313313Anything outside it, including macros, colours and links, shows as
314source, as does an expression over 8 KiB or nested more than 64 deep.
315The CLI shows the source.
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.
316320
317321* Organizations
318322
CHANGELOG.org +5 −3
@@ -49,9 +49,11 @@ anything beyond "replace the binary and restart" is needed.
4949 keeps at most 50 saved queries, 10 pinned (#292).
5050- TeX math renders server-side as MathML in markdown (=$…$=, =$$…$$=)
5151 and org (=$…$=, =\(…\)=, =\[…\]=, LaTeX environments) wherever
52 markup renders, through a subset converter in =internal/texmath=;
53 the sanitizer admits exactly the elements and attributes it emits,
54 and anything outside the subset shows as source (#294).
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).
5557
5658* v1.38.0 — 2026-09-29
5759
internal/httpd/math.go +221 −45
@@ -2,8 +2,12 @@ package httpd
22
33import (
44 "bytes"
5 "crypto/rand"
6 "encoding/hex"
57 "html/template"
68 "regexp"
9 "sort"
10 "strconv"
711 "strings"
812
913 "github.com/microcosm-cc/bluemonday"
@@ -28,21 +32,15 @@ import (
2832// logs to stderr; goldmark-mathml, which runs Temml in a JavaScript VM; and
2933// converters inside large typesetting modules. texmath covers a documented
3034// 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.
3139
32// mathHTML renders one expression, or escapes its source when the converter
33// refuses it. The result passes through ugcPolicy even on the markdown path,
34// so the policy is the one statement of what math may emit.
35func mathHTML(tex, source string, display bool) (string, bool) {
36 out, err := texmath.Convert(tex, display)
37 if err != nil {
38 return template.HTMLEscapeString(source), false
39 }
40 return ugcPolicy.Sanitize(out), true
41}
42
43// allowMath admits exactly the MathML texmath emits: its elements, and each
40// mathPolicy admits the MathML texmath emits: its elements, and each
4441// attribute only on its element and only with the values it writes.
45func allowMath(p *bluemonday.Policy) {
42var mathPolicy = func() *bluemonday.Policy {
43 p := bluemonday.NewPolicy()
4644 p.AllowElements(texmath.Elements...)
4745 p.AllowNoAttrs().OnElements(texmath.Elements...)
4846 for element, attrs := range texmath.Attrs {
@@ -54,6 +52,35 @@ func allowMath(p *bluemonday.Policy) {
5452 p.AllowAttrs(name).Matching(regexp.MustCompile(pattern)).OnElements(element)
5553 }
5654 }
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
5784}
5885
5986// Markdown: $…$ inline and $$…$$ display, inline or as a block.
@@ -61,8 +88,20 @@ func allowMath(p *bluemonday.Policy) {
6188var (
6289 kindMath = ast.NewNodeKind("Math")
6390 kindMathBlock = ast.NewNodeKind("MathBlock")
91
92 mathBudgetKey = parser.NewContextKey()
93 mathLineKey = parser.NewContextKey()
6494)
6595
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
66105type mathInline struct {
67106 ast.BaseInline
68107 tex string
@@ -77,6 +116,7 @@ func (n *mathInline) Dump(src []byte, level int) {
77116type mathBlock struct {
78117 ast.BaseBlock
79118 tex []byte
119 source []byte
80120 closed bool
81121}
82122
@@ -87,6 +127,32 @@ func (n *mathBlock) Dump(src []byte, level int) {
87127
88128func isMathSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' }
89129
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
90156// mathInlineParser follows pandoc's rule so prices stay prose: the opening
91157// $ has a non-space after it, and the closing $ a non-space before it and no
92158// digit after it. "$5 and $10" is text. A backslash escapes the next byte.
@@ -97,6 +163,8 @@ func (mathInlineParser) Trigger() []byte { return []byte{'$'} }
97163func (mathInlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
98164 line, seg := block.PeekLine()
99165 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.
100168 body := line[2:]
101169 for i := 0; i+1 < len(body); i++ {
102170 if body[i] == '\\' {
@@ -104,7 +172,7 @@ func (mathInlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Cont
104172 continue
105173 }
106174 if body[i] == '$' && body[i+1] == '$' {
107 if i == 0 {
175 if i == 0 || !budgetFor(pc).take(i) {
108176 break
109177 }
110178 block.Advance(i + 4)
@@ -115,33 +183,36 @@ func (mathInlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Cont
115183 block.Advance(2)
116184 return ast.NewTextSegment(seg.WithStop(seg.Start + 2))
117185 }
118 body := line[1:]
119 if len(body) == 0 || isMathSpace(body[0]) {
186 if len(line) < 2 || isMathSpace(line[1]) {
120187 return nil
121188 }
122 for i := 0; i < len(body); i++ {
123 switch body[i] {
124 case '\\':
125 i++
126 case '$':
127 if isMathSpace(body[i-1]) || i+1 < len(body) && body[i+1] >= '0' && body[i+1] <= '9' {
128 continue
129 }
130 block.Advance(i + 2)
131 return &mathInline{tex: string(body[:i])}
132 }
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
133198 }
134 return nil
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])}
135205}
136206
137// mathBlockParser opens on a line that is $$ alone, or $$…$$ whole, and
138// runs to the line that ends with $$.
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.
139210type mathBlockParser struct{}
140211
141212func (mathBlockParser) Trigger() []byte { return []byte{'$'} }
142213
143214func (mathBlockParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
144 line, _ := reader.PeekLine()
215 line, seg := reader.PeekLine()
145216 pos := pc.BlockOffset()
146217 if pos < 0 || !bytes.HasPrefix(line[pos:], []byte("$$")) {
147218 return nil, parser.NoChildren
@@ -150,8 +221,38 @@ func (mathBlockParser) Open(parent ast.Node, reader text.Reader, pc parser.Conte
150221 n := &mathBlock{}
151222 switch {
152223 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"...)
153250 case len(rest) > 2 && bytes.HasSuffix(rest, []byte("$$")):
251 if !budgetFor(pc).take(len(rest) - 2) {
252 return nil, parser.NoChildren
253 }
154254 n.tex, n.closed = rest[:len(rest)-2], true
255 n.source = append([]byte("$$"), rest...)
155256 default:
156257 return nil, parser.NoChildren
157258 }
@@ -165,17 +266,19 @@ func (mathBlockParser) Continue(node ast.Node, reader text.Reader, pc parser.Con
165266 return parser.Close
166267 }
167268 line, _ := reader.PeekLine()
168 if line == nil {
269 if line == nil || util.IsBlank(line) {
169270 return parser.Close
170271 }
171272 trimmed := util.TrimRightSpace(line)
172273 if bytes.HasSuffix(trimmed, []byte("$$")) {
173274 n.tex = append(n.tex, trimmed[:len(trimmed)-2]...)
275 n.source = append(n.source, trimmed...)
174276 n.closed = true
175277 reader.AdvanceToEOL()
176278 return parser.Close
177279 }
178280 n.tex = append(n.tex, line...)
281 n.source = append(n.source, line...)
179282 reader.AdvanceToEOL()
180283 return parser.Continue | parser.NoChildren
181284}
@@ -204,12 +307,11 @@ func (mathRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
204307 return ast.WalkContinue, nil
205308 }
206309 n := node.(*mathBlock)
207 source := "$$" + string(n.tex)
208 if n.closed {
209 source += "$$"
210 }
310 source := string(n.source)
211311 out, ok := mathHTML(string(n.tex), source, true)
212312 if !ok || !n.closed {
313 // A container that ended before the closing line leaves the
314 // block unclosed; show what it held.
213315 out = "<pre>" + template.HTMLEscapeString(source) + "</pre>"
214316 }
215317 _, _ = w.WriteString(out + "\n")
@@ -229,6 +331,83 @@ func (mathExtension) Extend(m goldmark.Markdown) {
229331// Org: go-org already parses $…$, $$…$$, \(…\), \[…\] and \begin{…}…\end{…}
230332// as LaTeX fragments, and \begin{…} on its own lines as a LaTeX block; it
231333// 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}
232411
233412func (w *orgWriter) WriteLatexFragment(l org.LatexFragment) {
234413 tex := org.String(l.Content...)
@@ -242,19 +421,16 @@ func (w *orgWriter) WriteLatexFragment(l org.LatexFragment) {
242421 if strings.HasPrefix(l.OpeningPair, `\begin{`) {
243422 tex = source
244423 }
245 out, ok := mathHTML(tex, source, display)
246 if !ok {
247 w.WriteText(org.Text{Content: source, IsRaw: true})
248 return
249 }
250 w.WriteString(out)
424 w.writeMath(tex, source, display)
251425}
252426
253427func (w *orgWriter) WriteLatexBlock(b org.LatexBlock) {
254428 tex := org.String(b.Content...)
255 if out, ok := mathHTML(tex, tex, true); ok {
256 w.WriteString(out + "\n")
257 return
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 }
258434 }
259435 w.WriteString("<pre>" + template.HTMLEscapeString(tex) + "</pre>\n")
260436}
internal/httpd/math_test.go +82 −6
@@ -2,6 +2,7 @@ package httpd
22
33import (
44 "net/http/httptest"
5 "regexp"
56 "strings"
67 "testing"
78 "time"
@@ -30,8 +31,10 @@ func TestMarkdownMath(t *testing.T) {
3031 {"fenced code", "```\n$x^2$\n$$\ny\n$$\n```\n", []string{"$x^2$", "$$\ny\n$$"}, []string{"<math"}},
3132 {"indented code", " $x$\n", []string{"$x$"}, []string{"<math"}},
3233 {"invalid inline", `see $\frac{a$ here`, []string{`see $\frac{a$ here`}, []string{"<math"}},
33 {"invalid block", "$$\n\\frac{\n$$\n", []string{"<pre tabindex=\"0\">$$\\frac{\n$$</pre>"}, []string{"<math"}},
34 {"unclosed block", "$$\nx\n", []string{"<pre"}, []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},
3538 {"markup is escaped", "$\\text{<b>&</b>}$", []string{`<mtext>&lt;b&gt;&amp;&lt;/b&gt;</mtext>`}, []string{"<b>"}},
3639 }
3740 for _, c := range cases {
@@ -82,16 +85,15 @@ func TestOrgMath(t *testing.T) {
8285 }
8386}
8487
85// ugcPolicy admits the MathML texmath writes and nothing else, which is
86// what an .html README or org's raw export would otherwise carry through.
87func TestUGCPolicyMathML(t *testing.T) {
88// mathPolicy admits the MathML texmath writes and nothing else.
89func TestMathPolicy(t *testing.T) {
8890 hostile := `<math display="block" xmlns:xlink="http://www.w3.org/1999/xlink" style="x" onclick="x()">` +
8991 `<mi href="javascript:alert(1)" xlink:href="javascript:alert(2)" mathvariant="bold" style="color:red" onmouseover="x()">x</mi>` +
9092 `<mo stretchy="true" form="prefix">(</mo><mspace width="expression(alert(3))"></mspace><mspace width="1em"></mspace>` +
9193 `<semantics><annotation-xml encoding="text/html"><img src=x onerror="alert(4)"></annotation-xml></semantics>` +
9294 `<maction actiontype="statusline"><mi>y</mi></maction><mstyle mathcolor="red"><mi>z</mi></mstyle>` +
9395 `<mtext><style>*{}</style><script>alert(5)</script></mtext></math><math display="inline"></math>`
94 out := ugcPolicy.Sanitize(hostile)
96 out := mathPolicy.Sanitize(hostile)
9597 for _, bad := range []string{"href", "xlink", "style", "onclick", "onmouseover", "onerror", "javascript",
9698 "annotation", "semantics", "maction", "mstyle", "mathcolor", "script", "expression",
9799 `mathvariant="bold"`, `stretchy="true"`, "form=", `display="inline"`} {
@@ -181,3 +183,77 @@ func TestIssuePageRendersMath(t *testing.T) {
181183 t.Errorf("autolink rewrote text inside <math>:\n%s", body)
182184 }
183185}
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 +6 −4
@@ -1353,7 +1353,6 @@ var ugcPolicy = func() *bluemonday.Policy {
13531353 p.AllowAttrs("class").
13541354 Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
13551355 OnElements("span", "pre", "code", "div")
1356 allowMath(p)
13571356 return p
13581357}()
13591358
@@ -1401,21 +1400,24 @@ func renderOrg(name string, raw []byte, contents bool, fallback func() template.
14011400 }
14021401 return fenceHighlight(source, lang)
14031402 }
1404 writer.ExtendingWriter = &orgWriter{writer}
1403 ow := &orgWriter{HTMLWriter: writer, math: newMathSlots()}
1404 writer.ExtendingWriter = ow
14051405 out, err := doc.Write(writer)
14061406 if err != nil {
14071407 return fallback()
14081408 }
1409 return imageAlt(template.HTML(ugcPolicy.Sanitize(out)))
1409 return imageAlt(template.HTML(ow.math.fill(ugcPolicy.Sanitize(out))))
14101410}
14111411
14121412// orgWriter overrides go-org's autolink rendering. go-org ends a bare URL
14131413// at the first character outside RFC 3986's set, and that set includes
14141414// `.`, `,` and `)`, so a URL closing a sentence or a parenthesis took the
14151415// punctuation with it. Org stops a plain link before trailing punctuation
1416// 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).
14171418type orgWriter struct {
14181419 *org.HTMLWriter
1420 math *mathSlots
14191421}
14201422
14211423func (w *orgWriter) WriteRegularLink(l org.RegularLink) {