Files
volumen/internal/markdown/math.go
T
petrbalvin f8ed33df83
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

539 lines
15 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package markdown
import (
"regexp"
"strconv"
"strings"
"sourcedock.dev/petrbalvin/scriptorium"
)
// The mathematics pass turns TeX runs into MathML: $$…$$ occupying a
// whole line becomes a display block, $…$ within one line becomes an
// inline element. scriptorium's Markdown grammar knows nothing about
// dollars, so the runs are lifted out of the source before rendering,
// each replaced by a placeholder of two private-use runes around its
// index, and the MathML is spliced back into the rendered HTML in the
// placeholder's place. A run outside the mappable surface is not
// refused: scriptorium degrades it in place, its verbatim source inside
// an merror element, so nothing is silently mistranslated.
// mathSpan is one equation lifted out of the source.
type mathSpan struct {
display bool
src string
}
// The placeholder runes come from Unicode's private-use area, so no
// authored body contains them; a body that somehow does is left without
// mathematics rather than spliced into the wrong place.
const (
sentinelOpen = '\uE000'
sentinelClose = '\uE001'
)
func mathToken(i int) string {
return string(sentinelOpen) + strconv.Itoa(i) + string(sentinelClose)
}
// lineKind tells how the previous emitted line ended, which is what the
// display rules need: an equation that follows text needs a separating
// blank line, or the renderer keeps it inside the paragraph above it.
type lineKind int
const (
prevBlank lineKind = iota
prevText
prevDisplay
)
// extractMath rewrites the source with placeholders and returns the
// equations in the order their placeholders appear.
func extractMath(src string) (string, []mathSpan) {
if strings.ContainsRune(src, sentinelOpen) || strings.ContainsRune(src, sentinelClose) {
return src, nil
}
var spans []mathSpan
next := func(display bool, src string) string {
spans = append(spans, mathSpan{display: display, src: src})
return mathToken(len(spans) - 1)
}
var out strings.Builder
emit := func(line string) {
out.WriteString(line)
out.WriteByte('\n')
}
fenceChar := byte(0)
fenceLen := 0
inCode := false
inHTML := false
pendingTick := 0
prev := prevBlank
// The lines are indexed rather than streamed: the multi-line display
// collector looks ahead from the line it stands on.
lines := strings.Split(src, "\n")
for i := 0; i < len(lines); i++ {
line := lines[i]
indent := len(line) - len(strings.TrimLeft(line, " \t"))
blank := strings.TrimSpace(line) == ""
// A fenced code block passes every line through verbatim.
if fenceChar != 0 {
emit(line)
if isClosingFence(line, fenceChar, fenceLen) {
fenceChar = 0
prev = prevBlank
}
continue
}
// The code-span cover of the line, carrying any run an earlier
// line left open. A span that has not closed keeps the whole
// line out of every other rule.
covered, open := codeSpans(line, pendingTick)
if open > 0 {
emit(line)
pendingTick = open
prev = prevText
continue
}
pendingTick = 0
startsCovered := len(covered) > 0 && covered[0]
if !startsCovered {
if c, n, ok := openingFence(line); ok {
fenceChar, fenceLen = c, n
emit(line)
continue
}
}
// An indented code block: entered from a blank line, left by the
// first line that is blank or carries less indentation.
if inCode {
if blank || indent >= 4 {
emit(line)
continue
}
inCode = false
} else if indent >= 4 && prev == prevBlank && !blank {
inCode = true
emit(line)
continue
}
// A raw HTML block runs to the next blank line, and its dollars
// are markup, not mathematics.
if inHTML {
emit(line)
if blank {
inHTML = false
prev = prevBlank
}
continue
}
if !startsCovered && startsHTMLBlock(line) {
inHTML = true
emit(line)
continue
}
if blank {
emit(line)
prev = prevBlank
continue
}
quotePrefix, rest := stripQuoteMarkers(line)
listPrefix, item, inList := stripListMarker(rest)
if inner, ok := displayMath(item); ok {
if !inList && prev != prevBlank {
emit(strings.TrimRight(quotePrefix, " \t"))
}
emit(quotePrefix + listPrefix + next(true, inner))
prev = prevDisplay
continue
}
if parts, end, ok := collectDisplayBlock(lines, i, item); ok {
if !inList && prev != prevBlank {
emit("")
}
emit(quotePrefix + listPrefix + next(true, strings.Join(parts, "\n")))
prev = prevDisplay
i = end
continue
}
if prev == prevDisplay && !inList {
emit(strings.TrimRight(quotePrefix, " \t"))
}
off := len(quotePrefix) + len(listPrefix)
emit(quotePrefix + listPrefix + renderInlineMath(item, covered, off, next))
prev = prevText
}
return out.String(), spans
}
// maxMathBlockLines bounds how far a multi-line display block may reach
// for its closing $$. A block the author never closed then falls back to
// literal text instead of swallowing the rest of the body.
const maxMathBlockLines = 64
// collectDisplayBlock looks ahead from lines[i], whose content opens a
// $$ block it does not close on the same line, for the line that closes
// it. The content lines between become the parts of one display
// equation. The collection stays inside one paragraph: a blank line, a
// code fence, a blockquote marker or the line bound ends the search and
// the block is refused, so a stray $$ stays the text it looks like.
func collectDisplayBlock(lines []string, i int, item string) (parts []string, end int, ok bool) {
if !strings.HasPrefix(item, "$$") {
return nil, 0, false
}
first := item[2:]
if strings.Contains(first, "$$") {
return nil, 0, false
}
if strings.TrimSpace(first) != "" {
parts = append(parts, first)
}
for j := i + 1; j < len(lines) && j-i <= maxMathBlockLines; j++ {
t := strings.TrimSpace(lines[j])
if t == "" || strings.HasPrefix(t, ">") {
return nil, 0, false
}
if _, _, fenced := openingFence(lines[j]); fenced {
return nil, 0, false
}
if strings.HasSuffix(t, "$$") && backslashRun(t, len(t)-2)%2 == 0 {
tail := t[:len(t)-2]
if strings.Contains(tail, "$$") {
return nil, 0, false
}
if strings.TrimSpace(tail) != "" {
parts = append(parts, tail)
}
if len(parts) == 0 || strings.TrimSpace(strings.Join(parts, "")) == "" {
return nil, 0, false
}
return parts, j, true
}
parts = append(parts, t)
}
return nil, 0, false
}
// renderInlineMath replaces the $…$ runs of one line with placeholders,
// leaving the bytes inside backtick code spans and the dollars written
// \$ alone. The cover was computed for the whole source line, so off
// tells where the line's remaining content begins in it.
func renderInlineMath(line string, covered []bool, off int, next func(bool, string) string) string {
var b strings.Builder
i := 0
for i < len(line) {
if i+off < len(covered) && covered[i+off] {
b.WriteByte(line[i])
i++
continue
}
if line[i] == '$' && backslashRun(line, i)%2 == 0 {
if value, consumed, ok := matchInlineMath(line[i:]); ok {
b.WriteString(next(false, value))
i += consumed
continue
}
}
b.WriteByte(line[i])
i++
}
return b.String()
}
// displayMath reports whether the whole of a line's content is one
// $$…$$ run, and returns the mathematics between the fences. A run that
// is empty, that hides another $$ or that shares its line with anything
// else is not a display equation.
func displayMath(t string) (string, bool) {
if len(t) < 5 || !strings.HasPrefix(t, "$$") || !strings.HasSuffix(t, "$$") {
return "", false
}
inner := t[2 : len(t)-2]
if strings.Contains(inner, "$$") || strings.TrimSpace(inner) == "" {
return "", false
}
return inner, true
}
// matchInlineMath matches one $…$ run at the head of line. The guards
// mirror the shape of real prose: the run must not be empty, may not
// start or end with a space, may not close before a digit, and may not
// contain a dollar inside, so a sentence with two dollar amounts does
// not become a phantom equation, and a run whose opening dollar is a
// closer of an equation broken across lines never swallows the prose
// around it into mathematics.
func matchInlineMath(line string) (value string, consumed int, ok bool) {
if len(line) < 3 || line[0] != '$' || line[1] == '$' || line[1] == ' ' {
return "", 0, false
}
for i := 1; i < len(line); i++ {
if line[i] == '\\' {
i++ // an escaped character is never the closer
continue
}
if line[i] != '$' || i == 1 {
continue
}
if line[i-1] == ' ' {
continue
}
if i+1 < len(line) && isDigit(line[i+1]) {
continue // currency: the next run of digits belongs outside
}
value := line[1:i]
if strings.ContainsRune(value, '$') {
return "", 0, false
}
return value, i + 1, true
}
return "", 0, false
}
func isDigit(b byte) bool { return '0' <= b && b <= '9' }
// backslashRun counts the backslashes immediately before line[i]; an
// odd count means the byte is escaped.
func backslashRun(line string, i int) int {
n := 0
for j := i - 1; j >= 0 && line[j] == '\\'; j-- {
n++
}
return n
}
// codeSpans marks the bytes of line that sit inside a backtick code
// span and reports the length of a run the line leaves open. A span
// opens with a run of n backticks and closes with the next run of
// exactly n; a run left open is carried to the next line by the
// pending state, and while it is open no dollar on the line starts
// mathematics.
func codeSpans(line string, pending int) (covered []bool, open int) {
covered = make([]bool, len(line))
open = pending
i := 0
if pending > 0 {
end := findTickRun(line, pending)
if end < 0 {
for j := range covered {
covered[j] = true
}
return covered, pending
}
for j := 0; j < end+pending; j++ {
covered[j] = true
}
i = end + pending
open = 0
}
for i < len(line) {
if line[i] != '`' {
i++
continue
}
n := 0
for i+n < len(line) && line[i+n] == '`' {
n++
}
end := findTickRun(line[i+n:], n)
if end < 0 {
if open == 0 {
open = n
}
i += n
continue
}
closeAt := i + n + end
for j := i; j < closeAt+n; j++ {
covered[j] = true
}
i = closeAt + n
}
return covered, open
}
// findTickRun returns the index in line where a run of exactly n
// backticks begins, or -1 when there is none.
func findTickRun(line string, n int) int {
for i := 0; i < len(line); {
if line[i] != '`' {
i++
continue
}
run := 0
for i+run < len(line) && line[i+run] == '`' {
run++
}
if run == n {
return i
}
i += run
}
return -1
}
// openingFence reports whether the line opens a fenced code block, and
// with which character and length.
func openingFence(line string) (byte, int, bool) {
t := strings.TrimLeft(line, " \t")
if len(line)-len(t) > 3 {
return 0, 0, false
}
if n := fenceRun(t, '`'); n > 0 {
return '`', n, true
}
if n := fenceRun(t, '~'); n > 0 {
return '~', n, true
}
return 0, 0, false
}
// fenceRun returns the length of a fence of c at the head of t, which
// the rest of the line may follow only with spaces, or 0 when this is
// not a fence.
func fenceRun(t string, c byte) int {
n := 0
for n < len(t) && t[n] == c {
n++
}
if n < 3 {
return 0
}
for _, r := range t[n:] {
if r != ' ' && r != '\t' {
return 0
}
}
return n
}
// isClosingFence reports whether the line closes an open fence.
func isClosingFence(line string, c byte, n int) bool {
t := strings.TrimLeft(line, " \t")
if len(line)-len(t) > 3 {
return false
}
run := 0
for run < len(t) && t[run] == c {
run++
}
if run < n {
return false
}
for _, r := range t[run:] {
if r != ' ' && r != '\t' {
return false
}
}
return true
}
// startsHTMLBlock approximates the CommonMark HTML block: a line that
// opens with a tag, a closing tag, a comment or a declaration starts
// one, and the block then runs to the next blank line.
func startsHTMLBlock(line string) bool {
t := strings.TrimLeft(line, " \t")
if len(t) < 2 || t[0] != '<' {
return false
}
c := t[1]
return c == '/' || c == '!' || c == '?' ||
('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z')
}
// stripQuoteMarkers removes the blockquote markers from the head of the
// line and returns everything consumed with what remains.
func stripQuoteMarkers(line string) (prefix, rest string) {
rest = line
for {
j := 0
for j < len(rest) && (rest[j] == ' ' || rest[j] == '\t') {
j++
}
if j >= len(rest) || rest[j] != '>' {
break
}
rest = rest[j+1:]
}
return line[:len(line)-len(rest)], rest
}
// stripListMarker removes one list marker from the head of the line, so
// an equation that is a list item's whole content is still recognised
// as display mathematics inside the item.
func stripListMarker(line string) (prefix, rest string, ok bool) {
j := 0
for j < len(line) && (line[j] == ' ' || line[j] == '\t') {
j++
}
rest = line[j:]
if strings.HasPrefix(rest, "- ") || strings.HasPrefix(rest, "* ") || strings.HasPrefix(rest, "+ ") {
rest = rest[2:]
} else {
digits := 0
for digits < len(rest) && digits < 9 && isDigit(rest[digits]) {
digits++
}
if digits == 0 || digits+1 >= len(rest) {
return "", line, false
}
if (rest[digits] == '.' || rest[digits] == ')') && rest[digits+1] == ' ' {
rest = rest[digits+2:]
} else {
return "", line, false
}
}
rest = strings.TrimLeft(rest, " \t")
if rest == "" {
return "", line, false
}
return line[:len(line)-len(rest)], rest, true
}
// spliceMath puts the rendered MathML into the HTML in each
// placeholder's place. A placeholder alone in its paragraph becomes the
// display wrapper; any other position takes the math element as it
// stands, which keeps the HTML valid where a display equation shares
// its paragraph with text or sits in a tight list item.
func spliceMath(html string, spans []mathSpan) string {
for i, span := range spans {
math := renderMathSpan(span)
alone := regexp.MustCompile(`(?s)<p>\s*` + mathToken(i) + `\s*</p>`)
if alone.MatchString(html) {
html = alone.ReplaceAllString(html, regexpEscapeRepl(math))
continue
}
html = strings.Replace(html, mathToken(i), math, 1)
}
return html
}
// regexpEscapeRepl guards the replacement text of ReplaceAllString,
// where a dollar sign would otherwise read as a capture group.
func regexpEscapeRepl(s string) string {
return strings.ReplaceAll(s, "$", "$$")
}
// renderMathSpan renders one equation through scriptorium. Rendering
// never fails: a construct outside the mappable surface degrades to its
// verbatim source inside an merror element, in place.
func renderMathSpan(span mathSpan) string {
if span.display {
return `<div class="math math-display">` + "\n" +
string(scriptorium.RenderMathDisplay([]byte(span.src))) + "\n</div>"
}
return string(scriptorium.RenderMath([]byte(span.src)))
}